From c8f435e8eda0b975d5f8bfa367c3cc1c2d383de6 Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Wed, 12 Jun 2019 13:27:44 -0400 Subject: [PATCH 001/632] Revised record_clusters_with_data function. --- tamr_unify_client/models/project/mastering.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tamr_unify_client/models/project/mastering.py b/tamr_unify_client/models/project/mastering.py index ee5c9a68..59bb36e1 100644 --- a/tamr_unify_client/models/project/mastering.py +++ b/tamr_unify_client/models/project/mastering.py @@ -88,4 +88,19 @@ def estimate_pairs(self): info = EstimatedPairCounts.from_json(self.client, estimate_json, api_path=alias) return info + def record_clusters_with_data(self): + """Record clusters with data. + + Call :func:`~tamr_unify_client.models.dataset.resource.Dataset.refresh` from + this dataset to generate clusters with data. + + Function is a workaround because API is broken. + + :returns: The record clusters with data represented as a dataset + :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` + """ + unified_dataset = self.unified_dataset() + name = unified_dataset.name + "_dedup_clusters_with_data" + return self.client.datasets.by_name(name) + # super.__repr__ is sufficient From 3b85dd56bc2f78b5f80f0ed595a89f561d5bd1f6 Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Wed, 12 Jun 2019 13:34:40 -0400 Subject: [PATCH 002/632] Testing for record_clusters_with_data --- tests/mock_api/test_continuous_mastering.py | 63 +++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/mock_api/test_continuous_mastering.py b/tests/mock_api/test_continuous_mastering.py index 04ad7753..0057a426 100644 --- a/tests/mock_api/test_continuous_mastering.py +++ b/tests/mock_api/test_continuous_mastering.py @@ -1,4 +1,6 @@ import os +import json +import responses import responses @@ -63,3 +65,64 @@ def test_continuous_mastering(): clause1 = project.estimate_pairs().clause_estimates["clause1"] assert clause1["generatedPairCount"] == "25" + +project_config = { + "name": "Project 1", + "description": "Mastering Project", + "type": "DEDUP", + "unifiedDatasetName": "Project 1 - Unified Dataset", + "externalId": "Project1", + "resourceId": "1", +} + +unified_dataset_json = { + "id": "unify://unified-data/v1/datasets/8", + "name": "Project_1_unified_dataset", + "version": "10", + "relativeId": "datasets/8", + "externalId": "Project_1_unified_dataset", +} + +datasets_json = [ + { + "id": "unify://unified-data/v1/datasets/36", + "name": "Project_1_unified_dataset_dedup_clusters_with_data", + "version": "251", + "relativeId": "datasets/36", + "externalId": "1", + } +] + + +rcwd_json = { + "externalId": "1", + "id": "unify://unified-data/v1/datasets/36", + "name": "Project_1_unified_dataset_dedup_clusters_with_data", + "relativeId": "datasets/36", + "version": "251", +} + + +@responses.activate +def test_rcwd(): + + auth = UsernamePasswordAuth("username", "password") + unify = Client(auth) + + project_external_id = "1" + + projects_url = ( + f"http://localhost:9100/api/versioned/v1/projects/{project_external_id}" + ) + unified_dataset_url = ( + f"http://localhost:9100/api/versioned/v1/projects/1/unifiedDataset" + ) + datasets_url = f"http://localhost:9100/api/versioned/v1/datasets" + + responses.add(responses.GET, projects_url, json=project_config) + responses.add(responses.GET, unified_dataset_url, json=unified_dataset_json) + responses.add(responses.GET, datasets_url, json=datasets_json) + + project = unify.projects.by_resource_id("1") + actual_rcwd_dataset = project.as_mastering().record_clusters_with_data() + assert actual_rcwd_dataset._data == rcwd_json From a35e745475f86d393425c0962c589f4a826f0e99 Mon Sep 17 00:00:00 2001 From: mollysacks <43151005+mollysacks@users.noreply.github.com> Date: Tue, 18 Jun 2019 14:05:51 -0400 Subject: [PATCH 003/632] Change homepage URL in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4faf3f58..634b911b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Python Client for the Tamr Unify API" license = "Apache-2.0" authors = ["Pedro Cattori "] readme = "README.md" -homepage = "tamr-unify-python-client.rtfd.io" +homepage = "https://tamr-unify-python-client.readthedocs.io/en/stable/" repository = "https://github.com/Datatamer/unify-client-python" keywords = ["tamr", "unify"] classifiers = [ From 340a34c09056e5d61b81cbd2841d8a038272f400 Mon Sep 17 00:00:00 2001 From: Molly Sacks Date: Tue, 18 Jun 2019 14:48:34 -0400 Subject: [PATCH 004/632] fix and test --- tamr_unify_client/models/dataset/resource.py | 21 ++++++++++++-------- tests/unit/test_dataset_geo.py | 2 +- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/tamr_unify_client/models/dataset/resource.py b/tamr_unify_client/models/dataset/resource.py index 8a385bdf..4fed1000 100644 --- a/tamr_unify_client/models/dataset/resource.py +++ b/tamr_unify_client/models/dataset/resource.py @@ -252,14 +252,19 @@ def _record_to_feature(record, key_value, key_attrs, geo_attr): reserved = {"bbox", geo_attr}.union(key_attrs) if geo_attr and geo_attr in record: src_geo = record[geo_attr] - for unify_attr in Dataset._geo_attr_names(): - if unify_attr in src_geo and src_geo[unify_attr]: - feature["geometry"] = { - # Convert e.g. multiLineString -> MultiLineString - "type": unify_attr[0].upper() + unify_attr[1:], - "coordinates": src_geo[unify_attr], - } - break + if src_geo: + for unify_attr in Dataset._geo_attr_names(): + if unify_attr in src_geo and src_geo[unify_attr]: + feature["geometry"] = { + # Convert e.g. multiLineString -> MultiLineString + "type": unify_attr[0].upper() + unify_attr[1:], + "coordinates": src_geo[unify_attr], + } + break + else: + feature["geometry"] = None + else: + feature["geometry"] = None if "bbox" in record: feature["bbox"] = record["bbox"] non_reserved = set(record.keys()).difference(reserved) diff --git a/tests/unit/test_dataset_geo.py b/tests/unit/test_dataset_geo.py index f74a895d..0c22d050 100644 --- a/tests/unit/test_dataset_geo.py +++ b/tests/unit/test_dataset_geo.py @@ -143,7 +143,7 @@ def key_value_single(rec): actual = Dataset._record_to_feature( record_with_null_geo, key_value_single, ["id"], "geom" ) - expected = {"type": "Feature", "id": "1"} + expected = {"geometry": None, "type": "Feature", "id": "1"} self.assertEqual(expected, actual) record_with_bbox = {"id": "1", "bbox": [[0, 0], [1, 1]]} From f8f8f74a37361f15c5c611d991ff6cafc0da4f61 Mon Sep 17 00:00:00 2001 From: Molly Sacks Date: Wed, 19 Jun 2019 10:45:56 -0400 Subject: [PATCH 005/632] fix extraneous change --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88a97f63..2e37054c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ ## 0.6.0-dev + **BUG FIXES** + - [#140](https://github.com/Datatamer/unify-client-python/issues/140) Dataset `itergeofeatures` now supports data with geo attribute NULL ## 0.5.0 **NEW FEATURES** From a975fea24be02b6fc11d27fd4c6d5ca06e5f4c9e Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Wed, 12 Jun 2019 13:52:02 -0400 Subject: [PATCH 006/632] Flake8, Black, & CHANGELOG edits. --- CHANGELOG.md | 1 + tamr_unify_client/models/project/mastering.py | 12 ++-- tests/mock_api/test_continuous_mastering.py | 63 ------------------- tests/unit/test_record_clusters_with_data.py | 52 +++++++++++++++ 4 files changed, 59 insertions(+), 69 deletions(-) create mode 100644 tests/unit/test_record_clusters_with_data.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 88a97f63..6b5d9cb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## 0.6.0-dev + - [#121](https://github.com/Datatamer/unify-client-python/issues/121) Fetches record clusters with data represented as a dataset. ## 0.5.0 **NEW FEATURES** diff --git a/tamr_unify_client/models/project/mastering.py b/tamr_unify_client/models/project/mastering.py index 59bb36e1..4d79cf0d 100644 --- a/tamr_unify_client/models/project/mastering.py +++ b/tamr_unify_client/models/project/mastering.py @@ -89,17 +89,17 @@ def estimate_pairs(self): return info def record_clusters_with_data(self): - """Record clusters with data. - - Call :func:`~tamr_unify_client.models.dataset.resource.Dataset.refresh` from - this dataset to generate clusters with data. - - Function is a workaround because API is broken. + """Project's unified dataset with associated clusters. :returns: The record clusters with data represented as a dataset :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` """ unified_dataset = self.unified_dataset() + + # Replace this workaround with a direct API call once API + # is fixed. APIs that need to work are: fetching the dataset and + # being able to call refresh on resulting dataset. Until then, we grab + # the dataset by constructing its name from the corresponding Unified Dataset's name name = unified_dataset.name + "_dedup_clusters_with_data" return self.client.datasets.by_name(name) diff --git a/tests/mock_api/test_continuous_mastering.py b/tests/mock_api/test_continuous_mastering.py index 0057a426..04ad7753 100644 --- a/tests/mock_api/test_continuous_mastering.py +++ b/tests/mock_api/test_continuous_mastering.py @@ -1,6 +1,4 @@ import os -import json -import responses import responses @@ -65,64 +63,3 @@ def test_continuous_mastering(): clause1 = project.estimate_pairs().clause_estimates["clause1"] assert clause1["generatedPairCount"] == "25" - -project_config = { - "name": "Project 1", - "description": "Mastering Project", - "type": "DEDUP", - "unifiedDatasetName": "Project 1 - Unified Dataset", - "externalId": "Project1", - "resourceId": "1", -} - -unified_dataset_json = { - "id": "unify://unified-data/v1/datasets/8", - "name": "Project_1_unified_dataset", - "version": "10", - "relativeId": "datasets/8", - "externalId": "Project_1_unified_dataset", -} - -datasets_json = [ - { - "id": "unify://unified-data/v1/datasets/36", - "name": "Project_1_unified_dataset_dedup_clusters_with_data", - "version": "251", - "relativeId": "datasets/36", - "externalId": "1", - } -] - - -rcwd_json = { - "externalId": "1", - "id": "unify://unified-data/v1/datasets/36", - "name": "Project_1_unified_dataset_dedup_clusters_with_data", - "relativeId": "datasets/36", - "version": "251", -} - - -@responses.activate -def test_rcwd(): - - auth = UsernamePasswordAuth("username", "password") - unify = Client(auth) - - project_external_id = "1" - - projects_url = ( - f"http://localhost:9100/api/versioned/v1/projects/{project_external_id}" - ) - unified_dataset_url = ( - f"http://localhost:9100/api/versioned/v1/projects/1/unifiedDataset" - ) - datasets_url = f"http://localhost:9100/api/versioned/v1/datasets" - - responses.add(responses.GET, projects_url, json=project_config) - responses.add(responses.GET, unified_dataset_url, json=unified_dataset_json) - responses.add(responses.GET, datasets_url, json=datasets_json) - - project = unify.projects.by_resource_id("1") - actual_rcwd_dataset = project.as_mastering().record_clusters_with_data() - assert actual_rcwd_dataset._data == rcwd_json diff --git a/tests/unit/test_record_clusters_with_data.py b/tests/unit/test_record_clusters_with_data.py new file mode 100644 index 00000000..01c303c8 --- /dev/null +++ b/tests/unit/test_record_clusters_with_data.py @@ -0,0 +1,52 @@ +import responses + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth + + +@responses.activate +def test_record_clusters_with_data(): + + project_config = { + "name": "Project 1", + "description": "Mastering Project", + "type": "DEDUP", + "unifiedDatasetName": "Project 1 - Unified Dataset", + "externalId": "Project1", + "resourceId": "1", + } + + unified_dataset_json = { + "id": "unify://unified-data/v1/datasets/8", + "name": "Project_1_unified_dataset", + "version": "10", + "relativeId": "datasets/8", + "externalId": "Project_1_unified_dataset", + } + + rcwd_json = { + "externalId": "1", + "id": "unify://unified-data/v1/datasets/36", + "name": "Project_1_unified_dataset_dedup_clusters_with_data", + "relativeId": "datasets/36", + "version": "251", + } + + datasets_json = [rcwd_json] + + unify = Client(UsernamePasswordAuth("username", "password")) + + project_id = "1" + + project_url = f"http://localhost:9100/api/versioned/v1/projects/{project_id}" + unified_dataset_url = ( + f"http://localhost:9100/api/versioned/v1/projects/{project_id}/unifiedDataset" + ) + datasets_url = f"http://localhost:9100/api/versioned/v1/datasets" + + responses.add(responses.GET, project_url, json=project_config) + responses.add(responses.GET, unified_dataset_url, json=unified_dataset_json) + responses.add(responses.GET, datasets_url, json=datasets_json) + project = unify.projects.by_resource_id(project_id) + actual_rcwd_dataset = project.as_mastering().record_clusters_with_data() + assert actual_rcwd_dataset.name == rcwd_json["name"] From e64527ef391e779a909e1ab4310b9ff63eb73084 Mon Sep 17 00:00:00 2001 From: Molly Sacks Date: Thu, 20 Jun 2019 10:49:30 -0400 Subject: [PATCH 007/632] move methods & update testing --- tamr_unify_client/client.py | 36 ------------------- .../models/dataset/collection.py | 12 +++++++ .../models/project/collection.py | 17 ++++++++- tests/unit/test_create_dataset.py | 2 +- tests/unit/test_create_project.py | 2 +- 5 files changed, 30 insertions(+), 39 deletions(-) diff --git a/tamr_unify_client/client.py b/tamr_unify_client/client.py index bb23b42a..55067c1a 100644 --- a/tamr_unify_client/client.py +++ b/tamr_unify_client/client.py @@ -145,42 +145,6 @@ def datasets(self): """ return self._datasets - def create_project(self, project_creation_spec): - """ - Create a Project in Unify - - :param project_creation_spec: Project creation specification should be formatted as specified in the `Public Docs for Creating a Project `_. - :type project_creation_spec: dict[str, str] - :returns: The created Project - :rtype: :class:`~tamr_unify_client.models.project.resource.Project` - """ - from tamr_unify_client.models.project.resource import Project - - data = ( - self.post(self.projects.api_path, json=project_creation_spec) - .successful() - .json() - ) - return Project.from_json(self, data) - - def create_dataset(self, dataset_creation_spec): - """ - Create a Dataset in Unify - - :param dataset_creation_spec: Project creation specification should be formatted as specified in the `Public Docs for Creating a Dataset `_. - :type dataset_creation_spec: dict[str, str] - :returns: The created Dataset - :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` - """ - from tamr_unify_client.models.dataset.resource import Dataset - - data = ( - self.post(self.datasets.api_path, json=dataset_creation_spec) - .successful() - .json() - ) - return Dataset.from_json(self, data) - def __repr__(self): # Show only the type `auth` to mitigate any security concerns. return ( diff --git a/tamr_unify_client/models/dataset/collection.py b/tamr_unify_client/models/dataset/collection.py index e3b4add5..90cd05f1 100644 --- a/tamr_unify_client/models/dataset/collection.py +++ b/tamr_unify_client/models/dataset/collection.py @@ -77,4 +77,16 @@ def by_name(self, dataset_name): return dataset raise KeyError(f"No dataset found with name: {dataset_name}") + def create_dataset(self, dataset_creation_spec): + """ + Create a Dataset in Unify + + :param dataset_creation_spec: Dataset creation specification should be formatted as specified in the `Public Docs for Creating a Dataset `_. + :type dataset_creation_spec: dict[str, str] + :returns: The created Dataset + :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` + """ + data = self.client.post(self.api_path, json=dataset_creation_spec).successful().json() + return Dataset.from_json(self, data) + # super.__repr__ is sufficient diff --git a/tamr_unify_client/models/project/collection.py b/tamr_unify_client/models/project/collection.py index 2b49a235..580cdf5e 100644 --- a/tamr_unify_client/models/project/collection.py +++ b/tamr_unify_client/models/project/collection.py @@ -1,7 +1,6 @@ from tamr_unify_client.models.base_collection import BaseCollection from tamr_unify_client.models.project.resource import Project - class ProjectCollection(BaseCollection): """Collection of :class:`~tamr_unify_client.models.project.resource.Project` s. @@ -61,5 +60,21 @@ def stream(self): >>> do_stuff(project) """ return super().stream(Project) + + def create_project(self, project_creation_spec): + """ + Create a Project in Unify + + :param project_creation_spec: Project creation specification should be formatted as specified in the `Public Docs for Creating a Project `_. + :type project_creation_spec: dict[str, str] + :returns: The created Project + :rtype: :class:`~tamr_unify_client.models.project.resource.Project` + """ + data = ( + self.client.post(self.api_path, json=project_creation_spec) + .successful() + .json() + ) + return Project.from_json(self, data) # super.__repr__ is sufficient diff --git a/tests/unit/test_create_dataset.py b/tests/unit/test_create_dataset.py index 9d243b68..a107618c 100644 --- a/tests/unit/test_create_dataset.py +++ b/tests/unit/test_create_dataset.py @@ -25,7 +25,7 @@ def test_create_dataset(): responses.add(responses.POST, datasets_url, json=dataset_creation_spec, status=204) responses.add(responses.GET, dataset_url, json=dataset_creation_spec) - u = unify.create_dataset(dataset_creation_spec) + u = unify.datasets.create_dataset(dataset_creation_spec) p = unify.datasets.by_resource_id("1") assert u.name == p.name assert u.key_attribute_names == p.key_attribute_names diff --git a/tests/unit/test_create_project.py b/tests/unit/test_create_project.py index 85bd2e05..c9347592 100644 --- a/tests/unit/test_create_project.py +++ b/tests/unit/test_create_project.py @@ -26,6 +26,6 @@ def test_create_project(): responses.add(responses.POST, projects_url, json=project_creation_spec, status=204) responses.add(responses.GET, project_url, json=project_creation_spec) - u = unify.create_project(project_creation_spec) + u = unify.projects.create_project(project_creation_spec) p = unify.projects.by_resource_id("1") assert print(p) == print(u) From 23d49a14ff1899170cc38628dd484011f3a5558f Mon Sep 17 00:00:00 2001 From: mollysacks <43151005+mollysacks@users.noreply.github.com> Date: Thu, 20 Jun 2019 10:55:37 -0400 Subject: [PATCH 008/632] remove extraneous change --- tamr_unify_client/models/project/collection.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tamr_unify_client/models/project/collection.py b/tamr_unify_client/models/project/collection.py index 580cdf5e..f73f535f 100644 --- a/tamr_unify_client/models/project/collection.py +++ b/tamr_unify_client/models/project/collection.py @@ -1,6 +1,7 @@ from tamr_unify_client.models.base_collection import BaseCollection from tamr_unify_client.models.project.resource import Project + class ProjectCollection(BaseCollection): """Collection of :class:`~tamr_unify_client.models.project.resource.Project` s. From 5ff5e11f905cca2fd1c88174e3bf41006a47a622 Mon Sep 17 00:00:00 2001 From: Molly Sacks Date: Thu, 20 Jun 2019 11:13:40 -0400 Subject: [PATCH 009/632] CHANGELOG.md and style --- CHANGELOG.md | 8 +++++++- tamr_unify_client/models/dataset/collection.py | 6 +++++- tamr_unify_client/models/project/collection.py | 3 ++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e37054c..a395e668 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,10 @@ ## 0.6.0-dev + **BREAKING CHANGES** + - [#150](https://github.com/Datatamer/unify-client-python/issues/150) Move `create_project` and `create_dataset` from client.py to corresponding collection.py + + **NEW FEATURES** + - [#121](https://github.com/Datatamer/unify-client-python/issues/121) Fetches record clusters with data represented as a dataset. + **BUG FIXES** - [#140](https://github.com/Datatamer/unify-client-python/issues/140) Dataset `itergeofeatures` now supports data with geo attribute NULL @@ -14,7 +20,7 @@ - [#114](https://github.com/Datatamer/unify-client-python/issues/114) Add support for generating pairs estimate - [#106](https://github.com/Datatamer/unify-client-python/issues/106) Add support for initializing a source dataset - [#107](https://github.com/Datatamer/unify-client-python/issues/107) Add support for creating a dataset attribute - + **BUG FIXES** - [#118](https://github.com/Datatamer/unify-client-python/issues/118) Fix JSON sent for Dataset.update_records diff --git a/tamr_unify_client/models/dataset/collection.py b/tamr_unify_client/models/dataset/collection.py index 90cd05f1..70d05953 100644 --- a/tamr_unify_client/models/dataset/collection.py +++ b/tamr_unify_client/models/dataset/collection.py @@ -86,7 +86,11 @@ def create_dataset(self, dataset_creation_spec): :returns: The created Dataset :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` """ - data = self.client.post(self.api_path, json=dataset_creation_spec).successful().json() + data = ( + self.client.post(self.api_path, json=dataset_creation_spec) + .successful() + .json() + ) return Dataset.from_json(self, data) # super.__repr__ is sufficient diff --git a/tamr_unify_client/models/project/collection.py b/tamr_unify_client/models/project/collection.py index 580cdf5e..12c0fabb 100644 --- a/tamr_unify_client/models/project/collection.py +++ b/tamr_unify_client/models/project/collection.py @@ -1,6 +1,7 @@ from tamr_unify_client.models.base_collection import BaseCollection from tamr_unify_client.models.project.resource import Project + class ProjectCollection(BaseCollection): """Collection of :class:`~tamr_unify_client.models.project.resource.Project` s. @@ -60,7 +61,7 @@ def stream(self): >>> do_stuff(project) """ return super().stream(Project) - + def create_project(self, project_creation_spec): """ Create a Project in Unify From debef5466f094d432926ed372c5b02633061c75d Mon Sep 17 00:00:00 2001 From: mollysacks <43151005+mollysacks@users.noreply.github.com> Date: Thu, 20 Jun 2019 11:35:47 -0400 Subject: [PATCH 010/632] change method name to `create` --- tamr_unify_client/models/dataset/collection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_unify_client/models/dataset/collection.py b/tamr_unify_client/models/dataset/collection.py index 70d05953..aacca924 100644 --- a/tamr_unify_client/models/dataset/collection.py +++ b/tamr_unify_client/models/dataset/collection.py @@ -77,7 +77,7 @@ def by_name(self, dataset_name): return dataset raise KeyError(f"No dataset found with name: {dataset_name}") - def create_dataset(self, dataset_creation_spec): + def create(self, dataset_creation_spec): """ Create a Dataset in Unify From 0c572e5de5fcf2da31902cff792dd767ed6f4e72 Mon Sep 17 00:00:00 2001 From: mollysacks <43151005+mollysacks@users.noreply.github.com> Date: Thu, 20 Jun 2019 11:38:37 -0400 Subject: [PATCH 011/632] change method and variable names --- tamr_unify_client/models/project/collection.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tamr_unify_client/models/project/collection.py b/tamr_unify_client/models/project/collection.py index 12c0fabb..afa38a53 100644 --- a/tamr_unify_client/models/project/collection.py +++ b/tamr_unify_client/models/project/collection.py @@ -62,17 +62,17 @@ def stream(self): """ return super().stream(Project) - def create_project(self, project_creation_spec): + def create(self, creation_spec): """ Create a Project in Unify - :param project_creation_spec: Project creation specification should be formatted as specified in the `Public Docs for Creating a Project `_. - :type project_creation_spec: dict[str, str] + :param creation_spec: Project creation specification should be formatted as specified in the `Public Docs for Creating a Project `_. + :type creation_spec: dict[str, str] :returns: The created Project :rtype: :class:`~tamr_unify_client.models.project.resource.Project` """ data = ( - self.client.post(self.api_path, json=project_creation_spec) + self.client.post(self.api_path, json=creation_spec) .successful() .json() ) From cb970d5ac47cb5853e6db4d2352be0a36145495f Mon Sep 17 00:00:00 2001 From: mollysacks <43151005+mollysacks@users.noreply.github.com> Date: Thu, 20 Jun 2019 11:39:19 -0400 Subject: [PATCH 012/632] change variable name --- tamr_unify_client/models/dataset/collection.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tamr_unify_client/models/dataset/collection.py b/tamr_unify_client/models/dataset/collection.py index aacca924..f16d7e93 100644 --- a/tamr_unify_client/models/dataset/collection.py +++ b/tamr_unify_client/models/dataset/collection.py @@ -77,17 +77,17 @@ def by_name(self, dataset_name): return dataset raise KeyError(f"No dataset found with name: {dataset_name}") - def create(self, dataset_creation_spec): + def create(self, creation_spec): """ Create a Dataset in Unify - :param dataset_creation_spec: Dataset creation specification should be formatted as specified in the `Public Docs for Creating a Dataset `_. - :type dataset_creation_spec: dict[str, str] + :param creation_spec: Dataset creation specification should be formatted as specified in the `Public Docs for Creating a Dataset `_. + :type creation_spec: dict[str, str] :returns: The created Dataset :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` """ data = ( - self.client.post(self.api_path, json=dataset_creation_spec) + self.client.post(self.api_path, json=creation_spec) .successful() .json() ) From fc7121f081cc131f28680abdbf0a5d316db49f96 Mon Sep 17 00:00:00 2001 From: Molly Sacks Date: Thu, 20 Jun 2019 11:50:21 -0400 Subject: [PATCH 013/632] change varible and method names --- tamr_unify_client/models/dataset/collection.py | 6 +----- tamr_unify_client/models/project/collection.py | 6 +----- tests/unit/test_create_dataset.py | 8 ++++---- tests/unit/test_create_project.py | 8 ++++---- 4 files changed, 10 insertions(+), 18 deletions(-) diff --git a/tamr_unify_client/models/dataset/collection.py b/tamr_unify_client/models/dataset/collection.py index f16d7e93..7eafdad0 100644 --- a/tamr_unify_client/models/dataset/collection.py +++ b/tamr_unify_client/models/dataset/collection.py @@ -86,11 +86,7 @@ def create(self, creation_spec): :returns: The created Dataset :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` """ - data = ( - self.client.post(self.api_path, json=creation_spec) - .successful() - .json() - ) + data = self.client.post(self.api_path, json=creation_spec).successful().json() return Dataset.from_json(self, data) # super.__repr__ is sufficient diff --git a/tamr_unify_client/models/project/collection.py b/tamr_unify_client/models/project/collection.py index afa38a53..5ddb511c 100644 --- a/tamr_unify_client/models/project/collection.py +++ b/tamr_unify_client/models/project/collection.py @@ -71,11 +71,7 @@ def create(self, creation_spec): :returns: The created Project :rtype: :class:`~tamr_unify_client.models.project.resource.Project` """ - data = ( - self.client.post(self.api_path, json=creation_spec) - .successful() - .json() - ) + data = self.client.post(self.api_path, json=creation_spec).successful().json() return Project.from_json(self, data) # super.__repr__ is sufficient diff --git a/tests/unit/test_create_dataset.py b/tests/unit/test_create_dataset.py index a107618c..a2d775d0 100644 --- a/tests/unit/test_create_dataset.py +++ b/tests/unit/test_create_dataset.py @@ -11,7 +11,7 @@ @responses.activate def test_create_dataset(): - dataset_creation_spec = { + creation_spec = { "id": "unify://unified-data/v1/datasets/1", "name": "dataset", "keyAttributeNames": ["F1"], @@ -22,10 +22,10 @@ def test_create_dataset(): datasets_url = f"http://localhost:9100/api/versioned/v1/datasets" dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/1" - responses.add(responses.POST, datasets_url, json=dataset_creation_spec, status=204) - responses.add(responses.GET, dataset_url, json=dataset_creation_spec) + responses.add(responses.POST, datasets_url, json=creation_spec, status=204) + responses.add(responses.GET, dataset_url, json=creation_spec) - u = unify.datasets.create_dataset(dataset_creation_spec) + u = unify.datasets.create(creation_spec) p = unify.datasets.by_resource_id("1") assert u.name == p.name assert u.key_attribute_names == p.key_attribute_names diff --git a/tests/unit/test_create_project.py b/tests/unit/test_create_project.py index c9347592..7208e898 100644 --- a/tests/unit/test_create_project.py +++ b/tests/unit/test_create_project.py @@ -11,7 +11,7 @@ @responses.activate def test_create_project(): - project_creation_spec = { + creation_spec = { "name": "Project 1", "description": "Mastering Project", "type": "DEDUP", @@ -23,9 +23,9 @@ def test_create_project(): projects_url = f"http://localhost:9100/api/versioned/v1/projects" project_url = f"http://localhost:9100/api/versioned/v1/projects/1" - responses.add(responses.POST, projects_url, json=project_creation_spec, status=204) - responses.add(responses.GET, project_url, json=project_creation_spec) + responses.add(responses.POST, projects_url, json=creation_spec, status=204) + responses.add(responses.GET, project_url, json=creation_spec) - u = unify.projects.create_project(project_creation_spec) + u = unify.projects.create(creation_spec) p = unify.projects.by_resource_id("1") assert print(p) == print(u) From 25ac04821ca5ee25fb7809523c5cd6eab5b86dd7 Mon Sep 17 00:00:00 2001 From: CaspianA1 Date: Mon, 17 Jun 2019 13:54:18 -0400 Subject: [PATCH 014/632] Fix bug #123: when using Client.post for a custom api endpoint, the Client's base_api attribute is not used in creating the final url. --- CHANGELOG.md | 3 +- docs/user-guide/advanced-usage.rst | 14 ++---- tamr_unify_client/client.py | 10 ++++- tests/unit/test_base_path.py | 69 ++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 13 deletions(-) create mode 100644 tests/unit/test_base_path.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e37054c..e09460ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## 0.6.0-dev **BUG FIXES** - [#140](https://github.com/Datatamer/unify-client-python/issues/140) Dataset `itergeofeatures` now supports data with geo attribute NULL + - [#123](https://github.com/Datatamer/unify-client-python/issues/123) Fix base_path bug for a custom api endpoint ## 0.5.0 **NEW FEATURES** @@ -14,7 +15,7 @@ - [#114](https://github.com/Datatamer/unify-client-python/issues/114) Add support for generating pairs estimate - [#106](https://github.com/Datatamer/unify-client-python/issues/106) Add support for initializing a source dataset - [#107](https://github.com/Datatamer/unify-client-python/issues/107) Add support for creating a dataset attribute - + **BUG FIXES** - [#118](https://github.com/Datatamer/unify-client-python/issues/118) Fix JSON sent for Dataset.update_records diff --git a/docs/user-guide/advanced-usage.rst b/docs/user-guide/advanced-usage.rst index e1c7c1c6..41498b29 100644 --- a/docs/user-guide/advanced-usage.rst +++ b/docs/user-guide/advanced-usage.rst @@ -102,13 +102,13 @@ You can also use the ``get``, ``post``, ``put``, ``delete`` convenience methods:: # e.g. `get` convenience method - response = unify.get('relative/path/to/reosurce') + response = unify.get('relative/path/to/resource') Custom Host / Port / Base API path ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ If you need to repeatedly send requests to another port or base API path -(i.e. not ``api/versioned/v1/``), you can simply instantiate a different client. +(i.e. not ``/api/versioned/v1/``), you can simply instantiate a different client. Then just call ``request`` as described above:: @@ -124,15 +124,10 @@ Then just call ``request`` as described above:: auth, host="10.10.0.1", port=9090, - base_path="api/some_service/", + base_path="/api/some_service/", ) response = custom_client.get('relative/path/to/resource') -Note that any component of the base_path after the final slash will be ignored; -see the documentation on `urljoin -`_ -for details. - One-off authenticated request ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -150,5 +145,4 @@ Authentication provider directly to the ``requests`` library:: password = os.environ['UNIFY_PASSWORD'] auth = UsernamePasswordAuth(username, password) - response = requests.request('GET', 'some/specific/endpoint', auth=auth) - + response = requests.request('GET', 'some/specific/endpoint', auth=auth) \ No newline at end of file diff --git a/tamr_unify_client/client.py b/tamr_unify_client/client.py index bb23b42a..77c8863e 100644 --- a/tamr_unify_client/client.py +++ b/tamr_unify_client/client.py @@ -55,7 +55,7 @@ def __init__( host="localhost", protocol="http", port=9100, - base_path="api/versioned/v1/", + base_path="/api/versioned/v1/", session=None, ): self.auth = auth @@ -72,6 +72,12 @@ def __init__( self.logger = None # https://docs.python.org/3/howto/logging-cookbook.html#implementing-structured-logging + if not self.base_path.startswith("/"): + self.base_path = "/" + self.base_path + + if not self.base_path.endswith("/"): + self.base_path = self.base_path + "/" + def default_log_entry(method, url, response): return f"{method} {url} : {response.status_code}" @@ -97,7 +103,7 @@ def request(self, method, endpoint, **kwargs): :return: HTTP response :rtype: :class:`requests.Response` """ - url = urljoin(self.origin + "/" + self.base_path, endpoint) + url = urljoin(self.origin + self.base_path, endpoint) response = self.session.request(method, url, auth=self.auth, **kwargs) # logging diff --git a/tests/unit/test_base_path.py b/tests/unit/test_base_path.py new file mode 100644 index 00000000..1498c20a --- /dev/null +++ b/tests/unit/test_base_path.py @@ -0,0 +1,69 @@ +import responses + + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth + + +auth = UsernamePasswordAuth("username", "password") + +""" +This is a test file for testing imperfect base paths, and various other tests too. +The first five tests demonstrate that the client can handle badly written base paths and produce correct final urls. +Each test runs a request to the server, and produces the correct final url. +""" + + +@responses.activate +def test_base_path_no_trailing_slash(): + bad_base_path = "/api/versioned/v1" + unify = Client(auth, base_path=bad_base_path) + full_url = "http://localhost:9100/api/versioned/v1/datasets/1" + responses.add(responses.GET, full_url, status=200) + unify.get("datasets/1") + + +@responses.activate +def test_base_path_no_leading_slash(): + bad_base_path = "api/versioned/v1/" + unify = Client(auth, base_path=bad_base_path) + full_url = "http://localhost:9100/api/versioned/v1/datasets/1" + responses.add(responses.GET, full_url, status=200) + unify.get("datasets/1") + + +@responses.activate +def test_base_path_no_slash(): + bad_base_path = "api/versioned/v1" + unify = Client(auth, base_path=bad_base_path) + full_url = "http://localhost:9100/api/versioned/v1/datasets/1" + responses.add(responses.GET, full_url, status=200) + unify.get("datasets/1") + + +@responses.activate +def test_base_path_default_slash(): + standard_base_path = "/api/versioned/v1/" + unify = Client(auth, base_path=standard_base_path) + full_url = "http://localhost:9100/api/versioned/v1/datasets/1" + responses.add(responses.GET, full_url, status=200) + unify.get("datasets/1") + + +@responses.activate +def test_base_path_no_base_path(): + unify = Client(auth) + full_url = "http://localhost:9100/api/versioned/v1/datasets/2" + responses.add(responses.GET, full_url, status=400) + unify.get("datasets/2") + + +@responses.activate +def test_request_absolute_endpoint(): + endpoint = "/api/service/health" + full_url = f"http://localhost:9100{endpoint}" + responses.add(responses.GET, full_url, json={}) + client = Client(UsernamePasswordAuth("username", "password")) + # If client does not properly handle absolute paths, client.get() will + # raise a ConnectionRefused exception. + client.get(endpoint) From 085f68cc177df5582bc3e15b1f5df998ec488d22 Mon Sep 17 00:00:00 2001 From: CaspianA1 Date: Thu, 20 Jun 2019 10:50:10 -0400 Subject: [PATCH 015/632] Changelog newline. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e09460ab..c60eb170 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ - [#114](https://github.com/Datatamer/unify-client-python/issues/114) Add support for generating pairs estimate - [#106](https://github.com/Datatamer/unify-client-python/issues/106) Add support for initializing a source dataset - [#107](https://github.com/Datatamer/unify-client-python/issues/107) Add support for creating a dataset attribute - + **BUG FIXES** - [#118](https://github.com/Datatamer/unify-client-python/issues/118) Fix JSON sent for Dataset.update_records From fdd389230235a5f3133a63ab7e42a26eb2a36b89 Mon Sep 17 00:00:00 2001 From: Molly Sacks Date: Thu, 20 Jun 2019 14:29:46 -0400 Subject: [PATCH 016/632] CHANGELOG.md --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7338779..2d6b7f22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ -## 0.6.0-dev +## 0.7.0-dev + +## 0.6.0 **BREAKING CHANGES** - [#150](https://github.com/Datatamer/unify-client-python/issues/150) Move `create_project` and `create_dataset` from client.py to corresponding collection.py From 227ef13a741bf52ad416c960fe733b671cbf09aa Mon Sep 17 00:00:00 2001 From: mollysacks <43151005+mollysacks@users.noreply.github.com> Date: Thu, 20 Jun 2019 14:31:11 -0400 Subject: [PATCH 017/632] Update pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 634b911b..5c324227 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "tamr-unify-client" -version = "0.6.0-dev" +version = "0.7.0-dev" description = "Python Client for the Tamr Unify API" license = "Apache-2.0" authors = ["Pedro Cattori "] From caeb1a945ea21a87a90115e6079a4b17b6d99d9f Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Wed, 19 Jun 2019 11:02:39 -0400 Subject: [PATCH 018/632] Published clusters function (workaround). --- tamr_unify_client/models/project/mastering.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tamr_unify_client/models/project/mastering.py b/tamr_unify_client/models/project/mastering.py index 4d79cf0d..511c6a7e 100644 --- a/tamr_unify_client/models/project/mastering.py +++ b/tamr_unify_client/models/project/mastering.py @@ -68,14 +68,21 @@ def record_clusters(self): def published_clusters(self): """Published record clusters generated by Unify's pair-matching model. - Call :func:`~tamr_unify_client.models.dataset.resource.Dataset.refresh` from - this dataset to republish clusters according to the latest clustering. - :returns: The published clusters represented as a dataset. :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` """ + + unified_dataset = self.unified_dataset() + + # Replace this workaround with a direct API call once API + # is fixed. APIs that need to work are: fetching the dataset and + # being able to call refresh on resulting dataset. Until then, we grab + # the dataset by constructing its name from the corresponding Unified Dataset's name + name = unified_dataset.name + "_dedup_published_clusters" + canonical = self.client.datasets.by_name(name) + resource_json = canonical._data alias = self.api_path + "/publishedClusters" - return Dataset(self.client, None, alias) + return Dataset.from_json(self.client, resource_json, alias) def estimate_pairs(self): """Returns pair estimate information for a mastering project From 35d7a53d59d27618171053cabfd5908c879ef8f9 Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Wed, 19 Jun 2019 11:35:12 -0400 Subject: [PATCH 019/632] Published clusters unit test file. --- tests/unit/test_published_clusters.py | 103 ++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 tests/unit/test_published_clusters.py diff --git a/tests/unit/test_published_clusters.py b/tests/unit/test_published_clusters.py new file mode 100644 index 00000000..8a83a575 --- /dev/null +++ b/tests/unit/test_published_clusters.py @@ -0,0 +1,103 @@ +import responses + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth + + +@responses.activate +def test_published_clusters(): + + project_config_json = { + "id": "unify://unified-data/v1/projects/1", + "name": "Project_1", + "description": "Mastering Project", + "type": "DEDUP", + "unifiedDatasetName": "Project_1_unified_dataset", + "relativeId": "projects/1", + "externalId": "32b99cab-e01b-41e7-a29d-509165242c6f", + } + + unified_dataset_json = { + "id": "unify://unified-data/v1/datasets/8", + "name": "Project_1_unified_dataset", + "version": "10", + "relativeId": "datasets/8", + "externalId": "Project_1_unified_dataset", + } + + published_clusters_json = { + "id": "unify://unified-data/v1/datasets/32", + "name": "Project_1_unified_dataset_dedup_published_clusters", + "description": "All the mappings of records to clusters.", + "version": "253", + "relativeId": "datasets/32", + "externalId": "Project_1_unified_dataset_dedup_published_clusters", + } + + datasets_json = [published_clusters_json] + + refresh_json = { + "id": "93", + "type": "SPARK", + "description": "Publish clusters", + "status": { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark", + }, + "created": { + "username": "admin", + "time": "2019-06-24T15:58:48.734Z", + "version": "2407", + }, + "lastModified": { + "username": "admin", + "time": "2019-06-24T15:58:48.734Z", + "version": "2407", + }, + "relativeId": "operations/93", + } + + operations_json = { + "id": "93", + "type": "SPARK", + "description": "Publish clusters", + "status": { + "state": "SUCCEEDED", + "startTime": "2019-06-24T15:58:56.595Z", + "endTime": "2019-06-24T15:59:17.084Z", + }, + "created": { + "username": "admin", + "time": "2019-06-24T15:58:48.734Z", + "version": "2407", + }, + "lastModified": { + "username": "system", + "time": "2019-06-24T15:59:18.350Z", + "version": "2423", + }, + "relativeId": "operations/93", + } + + unify = Client(UsernamePasswordAuth("username", "password")) + project_id = "1" + + project_url = f"http://localhost:9100/api/versioned/v1/projects/{project_id}" + unified_dataset_url = ( + f"http://localhost:9100/api/versioned/v1/projects/{project_id}/unifiedDataset" + ) + datasets_url = f"http://localhost:9100/api/versioned/v1/datasets" + refresh_url = f"http://localhost:9100/api/versioned/v1/projects/{project_id}/publishedClusters:refresh" + operations_url = f"http://localhost:9100/api/versioned/v1/operations/93" + + responses.add(responses.GET, project_url, json=project_config_json) + responses.add(responses.GET, unified_dataset_url, json=unified_dataset_json) + responses.add(responses.GET, datasets_url, json=datasets_json) + responses.add(responses.POST, refresh_url, json=refresh_json) + responses.add(responses.GET, operations_url, json=operations_json) + project = unify.projects.by_resource_id(project_id) + actual_published_clusters_dataset = project.as_mastering().published_clusters() + actual_published_clusters_dataset.refresh() + assert actual_published_clusters_dataset.name == published_clusters_json["name"] From 510b64d805dc959efcade459a0ddd05425a90905 Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Fri, 21 Jun 2019 14:42:07 -0400 Subject: [PATCH 020/632] Updated response log. --- tests/response_logs/continuous_mastering.ndjson | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/response_logs/continuous_mastering.ndjson b/tests/response_logs/continuous_mastering.ndjson index ccd14a66..70d87e06 100644 --- a/tests/response_logs/continuous_mastering.ndjson +++ b/tests/response_logs/continuous_mastering.ndjson @@ -80,7 +80,9 @@ {"method": "POST", "url": "http://10.10.0.92:9100/api/versioned/v1/projects/1/recordClusters:refresh", "status": 200, "json": {"id": "497", "type": "SPARK", "description": "Clustering", "status": {"state": "PENDING", "startTime": "", "endTime": "", "message": "Job has not yet been submitted to the executor"}, "created": {"username": "epeck", "time": "2019-03-07T20:16:55.789Z", "version": "23830"}, "lastModified": {"username": "epeck", "time": "2019-03-07T20:16:55.789Z", "version": "23830"}, "relativeId": "operations/497"}} {"method": "GET", "url": "http://10.10.0.92:9100/api/versioned/v1/operations/497", "status": 200, "json": {"id": "497", "type": "SPARK", "description": "Clustering", "status": {"state": "RUNNING", "startTime": "", "endTime": "", "message": "Job has not yet been submitted to the executor"}, "created": {"username": "epeck", "time": "2019-03-07T20:16:55.789Z", "version": "23830"}, "lastModified": {"username": "epeck", "time": "2019-03-07T20:16:55.789Z", "version": "23830"}, "relativeId": "operations/497"}} {"method": "GET", "url": "http://10.10.0.92:9100/api/versioned/v1/operations/497", "status": 200, "json": {"id": "497", "type": "SPARK", "description": "Clustering", "status": {"state": "SUCCEEDED", "startTime": "", "endTime": "", "message": "Job has not yet been submitted to the executor"}, "created": {"username": "epeck", "time": "2019-03-07T20:16:55.789Z", "version": "23830"}, "lastModified": {"username": "epeck", "time": "2019-03-07T20:16:55.789Z", "version": "23830"}, "relativeId": "operations/497"}} -{"method": "POST", "url": "http://10.10.0.92:9100/api/versioned/v1/projects/1/publishedClusters:refresh", "status": 202, "json": {"id": "21", "type": "SPARK", "description": "Publish clusters", "status": {"state": "PENDING", "message": "Job has not yet been submitted to the executor"}, "created": {"username": "admin", "time": "2018-12-14T19:42:46.755Z", "version": "603"}, "lastModified": {"username": "admin", "time": "2018-12-14T19:42:46.755Z", "version": "603"}, "relativeId": "operations/21"}} +{"method": "GET", "url": "http://10.10.0.92:9100/api/versioned/v1/projects/1/unifiedDataset", "status": 200, "json": {"id": "unify://unified-data/v1/datasets/8", "name": "Project_1_unified_dataset", "description":"", "created":{"username":"admin","time":"2019-06-05T16:28:11.639Z","version":"83"}, "lastModified":{"username":"admin","time":"2019-06-10T15:06:24.856Z","version":"5983"}, "relativeId": "datasets/8"}} +{"method": "GET", "url": "http://10.10.0.92:9100/api/versioned/v1/datasets", "status": 200, "json": [{"id": "unify://unified-data/v1/datasets/32", "name": "Project_1_unified_dataset_dedup_published_clusters", "description": "All the mappings of records to clusters.", "unifiedDatasetName": "Project_1_unified_dataset", "created": {"username": "admin", "time": "2019-06-05T18:35:32.407Z", "version": "553"}, "lastModified": {"username": "admin", "time": "2019-06-11T14:00:38.576Z", "version": "6792"}, "relativeId": "datasets/32"}]} +{"method": "POST", "url": "http://10.10.0.92:9100/api/versioned/v1/projects/1/publishedClusters:refresh", "status": 202, "json": {"id": "21", "type": "SPARK", "description": "Publish clusters", "status": {"state": "PENDING", "startTime": "", "endTime": "", "message": "Job has not yet been submitted to Spark"}, "created": {"username": "admin", "time": "2019-06-24T15:58:48.734Z", "version": "2407"}, "lastModified": {"username": "admin", "time": "2019-06-24T15:58:48.734Z", "version": "2407"}, "relativeId": "operations/21"}} {"method": "GET", "url": "http://10.10.0.92:9100/api/versioned/v1/operations/21", "status": 200, "json": {"id": "21", "type": "SPARK", "description": "Publish clusters", "status": {"state": "PENDING", "message": "Job has not yet been submitted to the executor"}, "created": {"username": "admin", "time": "2018-12-14T19:42:46.755Z", "version": "603"}, "lastModified": {"username": "admin", "time": "2018-12-14T19:42:46.755Z", "version": "603"}, "relativeId": "operations/21"}} {"method": "GET", "url": "http://10.10.0.92:9100/api/versioned/v1/operations/21", "status": 200, "json": {"id": "21", "type": "SPARK", "description": "Publish clusters", "status": {"state": "PENDING", "message": "Job has not yet been submitted to the executor"}, "created": {"username": "admin", "time": "2018-12-14T19:42:46.755Z", "version": "603"}, "lastModified": {"username": "admin", "time": "2018-12-14T19:42:46.755Z", "version": "603"}, "relativeId": "operations/21"}} {"method": "GET", "url": "http://10.10.0.92:9100/api/versioned/v1/operations/21", "status": 200, "json": {"id": "21", "type": "SPARK", "description": "Publish clusters", "status": {"state": "PENDING", "message": "Job has not yet been submitted to the executor"}, "created": {"username": "admin", "time": "2018-12-14T19:42:46.755Z", "version": "603"}, "lastModified": {"username": "admin", "time": "2018-12-14T19:42:46.755Z", "version": "603"}, "relativeId": "operations/21"}} From 3576d4628efa50faeb47695242bf0850f874e818 Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Fri, 21 Jun 2019 15:19:11 -0400 Subject: [PATCH 021/632] CHANGELOG edit. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d6b7f22..6b79ea06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ ## 0.7.0-dev + **NEW FEATURES** + - [#65] (https://github.com/Datatamer/unify-client-python/issues/65) Fetches published clusters with data represented as a dataset. ## 0.6.0 **BREAKING CHANGES** From 01597d962c6680618bf9ce06bc7bcd516ebf0cde Mon Sep 17 00:00:00 2001 From: Nik Bates-Haus Date: Wed, 26 Jun 2019 20:24:52 -0400 Subject: [PATCH 022/632] Fix #159: Add newline to the end of CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b79ea06..e8921132 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,4 +102,4 @@ - Reference documentation - Autodoc should show inherited members ([details](https://github.com/pcattori/unify-client-python/commit/8356eb3d8ea995227e808a07d71de1bf3d7453c7)) - Autodoc warning about `**` in `param` docstrings ([details](https://github.com/pcattori/unify-client-python/commit/2a204b294a41e4b9eea5cc383569f6303d3a5206)) - - Shortened Sphinx references with `~` ([details](https://github.com/pcattori/unify-client-python/commit/9827e98dd7dab4eaeaef5e60197e280649de3737)) \ No newline at end of file + - Shortened Sphinx references with `~` ([details](https://github.com/pcattori/unify-client-python/commit/9827e98dd7dab4eaeaef5e60197e280649de3737)) From 9d38e372bee2666fa83f536900ae58b26208678f Mon Sep 17 00:00:00 2001 From: Nik Bates-Haus Date: Wed, 26 Jun 2019 19:19:08 -0400 Subject: [PATCH 023/632] Fix #156 - Dataset profile can be out of date. --- CHANGELOG.md | 3 + tamr_unify_client/models/dataset/resource.py | 36 +++-- tamr_unify_client/models/dataset_profile.py | 17 ++- tests/unit/test_dataset_profile.py | 147 +++++++++++++++---- 4 files changed, 159 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b79ea06..8e5e5190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,7 @@ ## 0.7.0-dev + **BREAKING CHANGES** + - [#156](https://github.com/Datatamer/unify-client-python/issues/156) Fetch Dataset profile, even if out of date. + **NEW FEATURES** - [#65] (https://github.com/Datatamer/unify-client-python/issues/65) Fetches published clusters with data represented as a dataset. diff --git a/tamr_unify_client/models/dataset/resource.py b/tamr_unify_client/models/dataset/resource.py index 4fed1000..fdc3b1a0 100644 --- a/tamr_unify_client/models/dataset/resource.py +++ b/tamr_unify_client/models/dataset/resource.py @@ -104,27 +104,37 @@ def refresh(self, **options): op = Operation.from_json(self.client, op_json) return op.apply_options(**options) - def profile(self, **options): - """Returns up to date profile information for a dataset, re-profiling if not up to date. + def profile(self): + """Returns profile information for a dataset. + + If profile information has not been generated, call create_profile() first. + If the returned profile information is out-of-date, you can call refresh() on the returned + object to bring it up-to-date. :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.models.operation.Operation` . :return: Dataset Profile information. :rtype: :class:`~tamr_unify_client.models.dataset_status.DatasetProfile` """ - profile_json = self.client.get(self.api_path + "/profile").successful().json() - info = DatasetProfile.from_json( + return DatasetProfile.from_json( self.client, profile_json, api_path=self.api_path + "/profile" ) - if info.is_up_to_date: - return info - else: - op_json = ( - self.client.post(self.api_path + "/profile:refresh").successful().json() - ) - op = Operation.from_json(self.client, op_json) - op.apply_options(**options) - return self.profile() + + def create_profile(self, **options): + """Create a profile for this dataset. + + If a profile already exists, the existing profile will be brought + up to date. + + :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.models.operation.Operation` . + See :func:`~tamr_unify_client.models.operation.Operation.apply_options` . + :return: the operation to create the profile. + """ + op_json = ( + self.client.post(self.api_path + "/profile:refresh").successful().json() + ) + op = Operation.from_json(self.client, op_json) + return op.apply_options(**options) def records(self): """Stream this dataset's records as Python dictionaries. diff --git a/tamr_unify_client/models/dataset_profile.py b/tamr_unify_client/models/dataset_profile.py index 9b38a9c5..738f38d6 100644 --- a/tamr_unify_client/models/dataset_profile.py +++ b/tamr_unify_client/models/dataset_profile.py @@ -1,4 +1,5 @@ from tamr_unify_client.models.base_resource import BaseResource +from tamr_unify_client.models.operation import Operation class DatasetProfile(BaseResource): @@ -64,6 +65,20 @@ def attribute_profiles(self) -> list: """ return self._data.get("attributeProfiles") + def refresh(self, **options): + """Updates the dataset profile if needed. + + The dataset profile is updated on the server; you will need to call + :func:`~tamr_unify_client.models.dataset.resource.Dataset.profile` + to retrieve the updated profile. + + :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.models.operation.Operation` . + See :func:`~tamr_unify_client.models.operation.Operation.apply_options` . + """ + op_json = self.client.post(self.api_path + ":refresh").successful().json() + op = Operation.from_json(self.client, op_json) + return op.apply_options(**options) + def __repr__(self) -> str: return ( f"{self.__class__.__module__}." @@ -71,7 +86,7 @@ def __repr__(self) -> str: f"relative_id={self.relative_id!r}, " f"dataset_name={self.dataset_name!r}, " f"relative_dataset_id={self.relative_dataset_id!r}, " - f"up_to_date={self.is_up_to_date!r}, " + f"is_up_to_date={self.is_up_to_date!r}, " f"profiled_data_version={self.profiled_data_version!r}, " f"profiled_at={self.profiled_at!r}, " f"simple_metrics={self.simple_metrics!r})" diff --git a/tests/unit/test_dataset_profile.py b/tests/unit/test_dataset_profile.py index 9b5b9b4f..6053294e 100644 --- a/tests/unit/test_dataset_profile.py +++ b/tests/unit/test_dataset_profile.py @@ -1,36 +1,123 @@ -import json +from unittest import TestCase import responses from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth -profile_json1 = { - "datasetName": "ds3", - "relativeDatasetId": "v1/datasets/3", - "isUpToDate": "false", - "profiledDataVersion": "3", - "profiledAt": { - "username": "system", - "time": "2019-06-05T14:23:25.860Z", - "version": "46", - }, - "simpleMetrics": [{"metricName": "rowCount", "metricValue": "1999"}], -} - - -@responses.activate -def test_dataset_profile(): - dataset_id = "3" - dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/{dataset_id}" - profile_url = f"{dataset_url}/profile" - profile_refresh_url = f"{profile_url}:refresh" - responses.add(responses.GET, dataset_url, json={}) - responses.add(responses.GET, profile_url, json=profile_json1) - responses.add(responses.POST, profile_refresh_url, json=[], status=204) - auth = UsernamePasswordAuth("username", "password") - unify = Client(auth) - - dataset = unify.datasets.by_resource_id(dataset_id) - profile = dataset.profile() - assert profile._data == profile_json1 + +class TestDatasetProfile(TestCase): + @responses.activate + def test_dataset_profile(self): + auth = UsernamePasswordAuth("username", "password") + client = Client(auth) + + dataset_id = "3" + dataset_url = f"{client.protocol}://{client.host}:{client.port}/api/versioned/v1/datasets/{dataset_id}" + profile_url = f"{dataset_url}/profile" + responses.add(responses.GET, dataset_url, json={}) + responses.add(responses.GET, profile_url, json=self.profile_stale) + + dataset = client.datasets.by_resource_id(dataset_id) + profile = dataset.profile() + self.assertEqual(self.profile_stale["datasetName"], profile.dataset_name) + self.assertEqual( + self.profile_stale["relativeDatasetId"], profile.relative_dataset_id + ) + self.assertEqual(self.profile_stale["isUpToDate"], profile.is_up_to_date) + self.assertEqual( + self.profile_stale["profiledDataVersion"], profile.profiled_data_version + ) + self.assertEqual(self.profile_stale["profiledAt"], profile.profiled_at) + self.assertEqual(self.profile_stale["simpleMetrics"], profile.simple_metrics) + self.assertEqual( + self.profile_stale["attributeProfiles"], profile.attribute_profiles + ) + + @responses.activate + def test_profile_refresh(self): + auth = UsernamePasswordAuth("username", "password") + client = Client(auth) + + dataset_id = "3" + dataset_url = f"{client.protocol}://{client.host}:{client.port}/api/versioned/v1/datasets/{dataset_id}" + profile_url = f"{dataset_url}/profile" + profile_refresh_url = f"{profile_url}:refresh" + responses.add(responses.GET, dataset_url, json={}) + responses.add(responses.GET, profile_url, json=self.profile_stale) + responses.add( + responses.POST, profile_refresh_url, json=self.operation_succeeded + ) + + dataset = client.datasets.by_resource_id(dataset_id) + profile = dataset.profile() + op = profile.refresh() + self.assertTrue(op.succeeded()) + + @responses.activate + def test_profile_create(self): + auth = UsernamePasswordAuth("username", "password") + client = Client(auth) + + dataset_id = "3" + dataset_url = f"{client.protocol}://{client.host}:{client.port}/api/versioned/v1/datasets/{dataset_id}" + profile_url = f"{dataset_url}/profile" + profile_refresh_url = f"{profile_url}:refresh" + responses.add(responses.GET, dataset_url, json={}) + # We need to ensure that, when creating the profile, + # nothing ever tries to access the (non-existent) profile. + responses.add(responses.GET, profile_url, status=404) + responses.add( + responses.POST, profile_refresh_url, json=self.operation_succeeded + ) + + dataset = client.datasets.by_resource_id(dataset_id) + op = dataset.create_profile() + self.assertTrue(op.succeeded()) + + profile_stale = { + "datasetName": "ds3", + "relativeDatasetId": "v1/datasets/3", + "isUpToDate": False, + "profiledDataVersion": "3", + "profiledAt": { + "username": "system", + "time": "2019-06-05T14:23:25.860Z", + "version": "46", + }, + "simpleMetrics": [{"metricName": "rowCount", "metricValue": "1999"}], + "attributeProfiles": [ + { + "attributeName": "attribute1", + "simpleMetrics": [ + {"metricName": "distinctValueCount", "metricValue": "1999"} + ], + "mostFrequentValues": [ + {"value": "value1", "frequency": "1999", "percentFrequency": 1.0} + ], + } + ], + } + + operation_succeeded = { + "id": "1", + "type": "SPARK", + "description": "Synthetic Operation", + "status": { + "state": "SUCCEEDED", + "startTime": "2018-12-14T19:34:00.273Z", + "endTime": "2018-12-14T19:34:14.573Z", + "message": "", + }, + "created": { + "username": "admin", + "time": "2018-12-14T19:33:50.538Z", + "version": "390", + }, + "lastModified": { + "username": "system", + "time": "2018-12-14T19:34:15.200Z", + "version": "399", + }, + "relativeId": "operations/1", + } From 2536ddeb6adeed60ef3c7d48a1919cbbdbe5f674 Mon Sep 17 00:00:00 2001 From: Nik Bates-Haus Date: Thu, 27 Jun 2019 14:56:03 -0400 Subject: [PATCH 024/632] Fix #161 - DatasetCollection.create() and ProjectCollection.create() don't work --- tamr_unify_client/models/attribute/collection.py | 12 ++++++++++++ tamr_unify_client/models/dataset/collection.py | 2 +- tamr_unify_client/models/project/collection.py | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/tamr_unify_client/models/attribute/collection.py b/tamr_unify_client/models/attribute/collection.py index e4d90c93..bb3b2471 100644 --- a/tamr_unify_client/models/attribute/collection.py +++ b/tamr_unify_client/models/attribute/collection.py @@ -91,4 +91,16 @@ def by_name(self, attribute_name): return attribute raise KeyError(f"No attribute found with name: {attribute_name}") + def create(self, creation_spec): + """ + Create an Attribute in this collection + + :param creation_spec: Attribute creation specification should be formatted as specified in the `Public Docs for adding an Attribute `_. + :type creation_spec: dict[str, str] + :returns: The created Attribute + :rtype: :class:`~tamr_unify_client.models.attribute.resource.Attribute` + """ + data = self.client.post(self.api_path, json=creation_spec).successful().json() + return Attribute.from_json(self.client, data) + # super.__repr__ is sufficient diff --git a/tamr_unify_client/models/dataset/collection.py b/tamr_unify_client/models/dataset/collection.py index 7eafdad0..f4aab1a0 100644 --- a/tamr_unify_client/models/dataset/collection.py +++ b/tamr_unify_client/models/dataset/collection.py @@ -87,6 +87,6 @@ def create(self, creation_spec): :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` """ data = self.client.post(self.api_path, json=creation_spec).successful().json() - return Dataset.from_json(self, data) + return Dataset.from_json(self.client, data) # super.__repr__ is sufficient diff --git a/tamr_unify_client/models/project/collection.py b/tamr_unify_client/models/project/collection.py index 5ddb511c..1cf4c407 100644 --- a/tamr_unify_client/models/project/collection.py +++ b/tamr_unify_client/models/project/collection.py @@ -72,6 +72,6 @@ def create(self, creation_spec): :rtype: :class:`~tamr_unify_client.models.project.resource.Project` """ data = self.client.post(self.api_path, json=creation_spec).successful().json() - return Project.from_json(self, data) + return Project.from_json(self.client, data) # super.__repr__ is sufficient From 0ffa3e62f98b1a4e03aa144e5b468ebdf8c68087 Mon Sep 17 00:00:00 2001 From: Nik Bates-Haus Date: Thu, 27 Jun 2019 16:50:42 -0400 Subject: [PATCH 025/632] Move Attribute creation from Dataset to AttributesCollection --- .../models/attribute/collection.py | 4 +++- tamr_unify_client/models/dataset/resource.py | 17 ----------------- tests/unit/test_dataset_attributes.py | 2 +- 3 files changed, 4 insertions(+), 19 deletions(-) diff --git a/tamr_unify_client/models/attribute/collection.py b/tamr_unify_client/models/attribute/collection.py index bb3b2471..d261537d 100644 --- a/tamr_unify_client/models/attribute/collection.py +++ b/tamr_unify_client/models/attribute/collection.py @@ -100,7 +100,9 @@ def create(self, creation_spec): :returns: The created Attribute :rtype: :class:`~tamr_unify_client.models.attribute.resource.Attribute` """ + from tamr_unify_client.models.attribute.resource import Attribute data = self.client.post(self.api_path, json=creation_spec).successful().json() - return Attribute.from_json(self.client, data) + alias = self.api_path + "/" + creation_spec["name"] + return Attribute.from_json(self.client, data, alias) # super.__repr__ is sufficient diff --git a/tamr_unify_client/models/dataset/resource.py b/tamr_unify_client/models/dataset/resource.py index fdc3b1a0..cdc6af8a 100644 --- a/tamr_unify_client/models/dataset/resource.py +++ b/tamr_unify_client/models/dataset/resource.py @@ -55,23 +55,6 @@ def attributes(self): resource_json = self.client.get(alias).successful().json() return AttributeCollection.from_json(self.client, resource_json, alias) - def create_attribute(self, attribute_creation_spec): - """Create an Attribute in Unify - - :param attribute_creation_spec: the name and type (and optional description) of the attribute to create, formatted as described in the `Public Docs for Adding an Attribute `_. - :type attribute_creation_spec: dict[str, object] - :return: the created Attribute - """ - from tamr_unify_client.models.attribute.resource import Attribute - - data = ( - self.client.post(self.attributes.api_path, json=attribute_creation_spec) - .successful() - .json() - ) - alias = self.attributes.api_path + "/" + attribute_creation_spec["name"] - return Attribute(self.client, data, alias) - def update_records(self, records): """Send a batch of record creations/updates/deletions to this dataset. diff --git a/tests/unit/test_dataset_attributes.py b/tests/unit/test_dataset_attributes.py index b7922788..21fb3edf 100644 --- a/tests/unit/test_dataset_attributes.py +++ b/tests/unit/test_dataset_attributes.py @@ -33,7 +33,7 @@ def test_dataset_attributes(): ) dataset = unify.datasets.by_resource_id("1") - create = dataset.create_attribute(attribute_creation_spec) + create = dataset.attributes.create(attribute_creation_spec) created = dataset.attributes.by_name("myAttribute") assert (create.relative_id) == (created.relative_id) From 7939464b49020d99b2ecffaa6169b2bb7197062b Mon Sep 17 00:00:00 2001 From: Nik Bates-Haus Date: Thu, 27 Jun 2019 16:56:05 -0400 Subject: [PATCH 026/632] Update changelog --- CHANGELOG.md | 6 +++++- tamr_unify_client/models/attribute/collection.py | 1 - 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82d025df..9c112309 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,13 @@ ## 0.7.0-dev **BREAKING CHANGES** - [#156](https://github.com/Datatamer/unify-client-python/issues/156) Fetch Dataset profile, even if out of date. + - [#161](https://github.com/Datatamer/unify-client-python/issues/161) Move `create_attribute` from Dataset to AttributeCollection **NEW FEATURES** - - [#65] (https://github.com/Datatamer/unify-client-python/issues/65) Fetches published clusters with data represented as a dataset. + - [#65](https://github.com/Datatamer/unify-client-python/issues/65) Fetches published clusters with data represented as a dataset. + + **BUG FIXES** + - [#161](https://github.com/Datatamer/unify-client-python/issues/161) DatasetCollection.create() and ProjectCollection.create() don't work ## 0.6.0 **BREAKING CHANGES** diff --git a/tamr_unify_client/models/attribute/collection.py b/tamr_unify_client/models/attribute/collection.py index d261537d..79eba8cb 100644 --- a/tamr_unify_client/models/attribute/collection.py +++ b/tamr_unify_client/models/attribute/collection.py @@ -100,7 +100,6 @@ def create(self, creation_spec): :returns: The created Attribute :rtype: :class:`~tamr_unify_client.models.attribute.resource.Attribute` """ - from tamr_unify_client.models.attribute.resource import Attribute data = self.client.post(self.api_path, json=creation_spec).successful().json() alias = self.api_path + "/" + creation_spec["name"] return Attribute.from_json(self.client, data, alias) From fffbbec3ef054710c46efe0d0bd92f7eafc6ebd7 Mon Sep 17 00:00:00 2001 From: CaspianA1 Date: Thu, 27 Jun 2019 17:00:41 -0400 Subject: [PATCH 027/632] tests for when geometry and id are null in feature,and when id is not present, edited resource to make tests pass --- tamr_unify_client/models/dataset/resource.py | 5 +++- tests/unit/test_dataset_geo.py | 24 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/tamr_unify_client/models/dataset/resource.py b/tamr_unify_client/models/dataset/resource.py index fdc3b1a0..c8ffe1de 100644 --- a/tamr_unify_client/models/dataset/resource.py +++ b/tamr_unify_client/models/dataset/resource.py @@ -294,6 +294,7 @@ def _feature_to_record(feature, key_attrs, geo_attr): :param geo_attr: The singluar attribute on the record to use for the geometry :return: dict """ + if hasattr(feature, "__geo_interface__"): feature = feature.__geo_interface__ @@ -314,9 +315,11 @@ def _feature_to_record(feature, key_attrs, geo_attr): bbox = feature.get("bbox") if bbox: record["bbox"] = bbox - + if "id" not in feature or feature["id"] is None: + raise ValueError("id must have a non-null value") if key_attrs[1:]: key_values = feature["id"] + for i, attr in enumerate(key_attrs): record[attr] = key_values[i] else: diff --git a/tests/unit/test_dataset_geo.py b/tests/unit/test_dataset_geo.py index 0c22d050..6022bfa6 100644 --- a/tests/unit/test_dataset_geo.py +++ b/tests/unit/test_dataset_geo.py @@ -3,6 +3,7 @@ import json from unittest import TestCase +import pytest import responses from tamr_unify_client import Client @@ -428,6 +429,29 @@ def test_feature_to_record(self): expected = {"pk1": "1", "pk2": "2", "geo": {"point": [0, 0]}} self.assertEqual(expected, actual) + feature = { + "type": "Feature", + "id": "1", + "geometry": None, + } + Dataset._feature_to_record(feature, ["pk"], "geo") + # feature_to_record is required to not raise an exception + + feature = { + "type": "Feature", + "id": None, + "geometry": {"type": "Point", "coordinates": [0, 0]}, + } + with pytest.raises(ValueError): + Dataset._feature_to_record(feature, ["pk"], "geo") + + feature = { + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [0, 0]}, + } + with pytest.raises(ValueError): + Dataset._feature_to_record(feature, ["pk"], "geo") + class NotAFeature: @property def __geo_interface__(self): From 41d4377f620160a276df5e679cbc82f0224846c0 Mon Sep 17 00:00:00 2001 From: CaspianA1 Date: Thu, 27 Jun 2019 17:30:43 -0400 Subject: [PATCH 028/632] Updated changelog for bug #148 --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82d025df..9a52e2e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,10 @@ - [#156](https://github.com/Datatamer/unify-client-python/issues/156) Fetch Dataset profile, even if out of date. **NEW FEATURES** - - [#65] (https://github.com/Datatamer/unify-client-python/issues/65) Fetches published clusters with data represented as a dataset. + - [#65](https://github.com/Datatamer/unify-client-python/issues/65) Fetches published clusters with data represented as a dataset. + + **BUG FIXES** + - [#148](https://github.com/Datatamer/unify-client-python/issues/148) Fix null geo, id and absent id bug for geospatial datasets. ## 0.6.0 **BREAKING CHANGES** From dd807dc0bf415c5f7b6e1ca9eba14ab6217880a4 Mon Sep 17 00:00:00 2001 From: CaspianA1 Date: Fri, 28 Jun 2019 10:08:54 -0400 Subject: [PATCH 029/632] Ran black and flake8 --- tests/unit/test_dataset_geo.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/unit/test_dataset_geo.py b/tests/unit/test_dataset_geo.py index 6022bfa6..6b46d509 100644 --- a/tests/unit/test_dataset_geo.py +++ b/tests/unit/test_dataset_geo.py @@ -429,11 +429,7 @@ def test_feature_to_record(self): expected = {"pk1": "1", "pk2": "2", "geo": {"point": [0, 0]}} self.assertEqual(expected, actual) - feature = { - "type": "Feature", - "id": "1", - "geometry": None, - } + feature = {"type": "Feature", "id": "1", "geometry": None} Dataset._feature_to_record(feature, ["pk"], "geo") # feature_to_record is required to not raise an exception From 6fb8c949bc5197c04890b93269609ef26b297526 Mon Sep 17 00:00:00 2001 From: Charlotte Moremen Date: Fri, 28 Jun 2019 10:36:09 -0400 Subject: [PATCH 030/632] method published_clusters_with_data --- tamr_unify_client/models/project/mastering.py | 12 ++++- tests/mock_api/test_continuous_mastering.py | 3 +- .../unit/test_published_clusters_with_data.py | 51 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 tests/unit/test_published_clusters_with_data.py diff --git a/tamr_unify_client/models/project/mastering.py b/tamr_unify_client/models/project/mastering.py index 511c6a7e..f8543a00 100644 --- a/tamr_unify_client/models/project/mastering.py +++ b/tamr_unify_client/models/project/mastering.py @@ -110,4 +110,14 @@ def record_clusters_with_data(self): name = unified_dataset.name + "_dedup_clusters_with_data" return self.client.datasets.by_name(name) - # super.__repr__ is sufficient + # super.__repr__ is sufficient + + def published_clusters_with_data(self): + """Project's unified dataset with associated clusters. + :returns: The published clusters with data represented as a dataset + :rtype :class `~tamr_unify_client.models.dataset.resource.Dataset` + """ + + unified_dataset = self.unified_dataset() + name = unified_dataset.name + "_dedup_published_clusters_with_data" + return self.client.datasets.by_name(name) diff --git a/tests/mock_api/test_continuous_mastering.py b/tests/mock_api/test_continuous_mastering.py index 04ad7753..4e296af2 100644 --- a/tests/mock_api/test_continuous_mastering.py +++ b/tests/mock_api/test_continuous_mastering.py @@ -1,10 +1,11 @@ import os import responses +from tests.mock_api.utils import mock_api from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth -from .utils import mock_api + basedir = os.path.dirname(__file__) response_log_path = os.path.join( diff --git a/tests/unit/test_published_clusters_with_data.py b/tests/unit/test_published_clusters_with_data.py new file mode 100644 index 00000000..ba6fec70 --- /dev/null +++ b/tests/unit/test_published_clusters_with_data.py @@ -0,0 +1,51 @@ +import responses + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth + + +@responses.activate +def test_published_clusters_with_data(): + project_config = { + "name": "Project 1", + "description": "Mastering Project", + "type": "DEDUP", + "unifiedDatasetName": "Project_1_unified_dataset", + "externalId": "Project1", + "resourceId": "1", + } + + unified_dataset_json = { + "id": "unify://unified-data/v1/datasets/8", + "name": "Project_1_unified_dataset", + "version": "10", + "relativeId": "datasets/8", + "externalId": "Project_1_unified_dataset", + } + + pcwd_json = { + "externalId": "1", + "id": "unify://unified-data/v1/datasets/36", + "name": "Project_1_unified_dataset_dedup_published_clusters_with_data", + "relativeId": "datasets/36", + "version": "251", + } + + datasets_json = [pcwd_json] + + unify = Client(UsernamePasswordAuth("username", "password")) + + project_id = "1" + + project_url = f"http://localhost:9100/api/versioned/v1/projects/{project_id}" + unified_dataset_url = ( + f"http://localhost:9100/api/versioned/v1/projects/{project_id}/unifiedDataset" + ) + datasets_url = f"http://localhost:9100/api/versioned/v1/datasets" + + responses.add(responses.GET, project_url, json=project_config) + responses.add(responses.GET, unified_dataset_url, json=unified_dataset_json) + responses.add(responses.GET, datasets_url, json=datasets_json) + project = unify.projects.by_resource_id(project_id) + actual_pcwd_dataset = project.as_mastering().published_clusters_with_data() + assert actual_pcwd_dataset.name == pcwd_json["name"] From 2ca50cbdf05f03da69cb263ee297faff52f9825e Mon Sep 17 00:00:00 2001 From: Nik Bates-Haus Date: Mon, 1 Jul 2019 14:52:37 -0400 Subject: [PATCH 031/632] Fix #165 - Dataset.itergeofeatures() is wicked slow --- tamr_unify_client/models/dataset/resource.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tamr_unify_client/models/dataset/resource.py b/tamr_unify_client/models/dataset/resource.py index b452ed3c..e572c4e5 100644 --- a/tamr_unify_client/models/dataset/resource.py +++ b/tamr_unify_client/models/dataset/resource.py @@ -205,8 +205,10 @@ def key_value(rec): def key_value(rec): return [rec[attr] for attr in key_attrs] + geo_attr = self._geo_attr + for record in self.records(): - yield self._record_to_feature(record, key_value, key_attrs, self._geo_attr) + yield self._record_to_feature(record, key_value, key_attrs, geo_attr) @property def _geo_attr(self): From 09cfb7a72ba92b71ec9693ec8404c4d8e0c9016a Mon Sep 17 00:00:00 2001 From: Nik Bates-Haus Date: Mon, 1 Jul 2019 17:12:31 -0400 Subject: [PATCH 032/632] Add `geo_attr` parameter to itergeofeatures and from_geo_features --- CHANGELOG.md | 2 + docs/user-guide/geo.rst | 9 +++ tamr_unify_client/models/dataset/resource.py | 20 ++++- tests/unit/test_dataset_geo.py | 78 ++++++++++++++++++++ 4 files changed, 105 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 243c694f..c2e0dcc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,12 @@ **NEW FEATURES** - [#65](https://github.com/Datatamer/unify-client-python/issues/65) Fetches published clusters with data represented as a dataset. + - [#165](https://github.com/Datatamer/unify-client-python/issues/165) Add `geo_attr` parameter to `Dataset.itergeofeatures()` and `Dataset.from_geo_features()` **BUG FIXES** - [#148](https://github.com/Datatamer/unify-client-python/issues/148) Fix null geo, id and absent id bug for geospatial datasets. - [#161](https://github.com/Datatamer/unify-client-python/issues/161) DatasetCollection.create() and ProjectCollection.create() don't work + - [#165](https://github.com/Datatamer/unify-client-python/issues/165) Dataset.itergeofeatures() is too slow ## 0.6.0 **BREAKING CHANGES** diff --git a/docs/user-guide/geo.rst b/docs/user-guide/geo.rst index e28339a0..bc84a4e3 100644 --- a/docs/user-guide/geo.rst +++ b/docs/user-guide/geo.rst @@ -40,6 +40,11 @@ To produce a GeoJSON representation of a dataset:: with open("my_dataset.json", "w") as f: json.dump(dataset.__geo_interface__, f) + +By default, ``itergeofeatures()`` will use the first dataset attribute with geometry type to fill +in the feature geometry. You can override this by specifying the geometry attribute to use in the +``geo_attr`` parameter to ``itergeofeatures``. + ``Dataset`` can also be updated from a feature collection that supports the Python Geo Interface:: import geopandas @@ -47,6 +52,10 @@ To produce a GeoJSON representation of a dataset:: dataset = client.dataset.by_name("my_dataset") dataset.from_geo_features(geodataframe) +By default the features' geometries will be placed into the first dataset attribute with geometry +type. You can override this by specifying the geometry attribute to use in the ``geo_attr`` +parameter to ``from_geo_features``. + Rules for converting from Unify records to Geospatial Features ------------------------------------------------------------------ diff --git a/tamr_unify_client/models/dataset/resource.py b/tamr_unify_client/models/dataset/resource.py index e572c4e5..82331f74 100644 --- a/tamr_unify_client/models/dataset/resource.py +++ b/tamr_unify_client/models/dataset/resource.py @@ -140,7 +140,7 @@ def status(self) -> DatasetStatus: self.client, status_json, api_path=self.api_path + "/status" ) - def from_geo_features(self, features): + def from_geo_features(self, features, geo_attr=None): """Upsert this dataset from a geospatial FeatureCollection or iterable of Features. `features` can be: @@ -153,7 +153,13 @@ def from_geo_features(self, features): See: geopandas.GeoDataFrame.from_features() + If geo_attr is provided, then the named Unify attribute will be used for the geometry. + If geo_attr is not provided, then the first attribute on the dataset with geometry type + will be used for the geometry. + :param features: geospatial features + :param geo_attr: (optional) name of the Unify attribute to use for the feature's geometry + :type geo_attr: str """ if hasattr(features, "__geo_interface__"): features = features.__geo_interface__ @@ -166,8 +172,11 @@ def from_geo_features(self, features): else: record_id = "compositeRecordId" + if geo_attr is None: + geo_attr = self._geo_attr + self.update_records( - self._features_to_updates(features, record_id, key_attrs, self._geo_attr) + self._features_to_updates(features, record_id, key_attrs, geo_attr) ) @property @@ -186,11 +195,13 @@ def __geo_interface__(self): "features": [feature for feature in self.itergeofeatures()], } - def itergeofeatures(self): + def itergeofeatures(self, geo_attr=None): """Returns an iterator that yields feature dictionaries that comply with __geo_interface__ See https://gist.github.com/sgillies/2217756 + :param geo_attr: (optional) name of the Unify attribute to use for the feature's geometry + :type geo_attr: str :return: stream of features :rtype: Python generator yielding :py:class:`dict[str, object]` """ @@ -205,7 +216,8 @@ def key_value(rec): def key_value(rec): return [rec[attr] for attr in key_attrs] - geo_attr = self._geo_attr + if geo_attr is None: + geo_attr = self._geo_attr for record in self.records(): yield self._record_to_feature(record, key_value, key_attrs, geo_attr) diff --git a/tests/unit/test_dataset_geo.py b/tests/unit/test_dataset_geo.py index 6b46d509..fd2ceac5 100644 --- a/tests/unit/test_dataset_geo.py +++ b/tests/unit/test_dataset_geo.py @@ -258,6 +258,33 @@ def test_geo_features(self): {feature["id"] for feature in features}, ) + @responses.activate + def test_geo_features_geo_attr(self): + dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/1" + responses.add(responses.GET, dataset_url, json=self._dataset_json) + + # Create a dataset with multiple geometry attributes + multi_geo_attrs = deepcopy(self._attributes_json) + geo2_attr = deepcopy(multi_geo_attrs[-1]) + geo2_attr["name"] = "geom2" + multi_geo_attrs.append(geo2_attr) + attributes_url = f"{dataset_url}/attributes" + responses.add(responses.GET, attributes_url, json=multi_geo_attrs) + + # Create a record with multiple geometry attributes + record = {"id": "point", "geom": {"point": [1, 1]}, "geom2": {"point": [2, 2]}} + records_url = f"{dataset_url}/records" + responses.add(responses.GET, records_url, body=json.dumps(record)) + dataset = self.unify.datasets.by_resource_id("1") + + # Default is to get the first attribute with geometry type + feature = next(dataset.itergeofeatures()) + self.assertEqual(feature["geometry"]["coordinates"], record["geom"]["point"]) + + # We can override which geometry attribute is used for geometry + feature = next(dataset.itergeofeatures(geo_attr="geom2")) + self.assertEqual(feature["geometry"]["coordinates"], record["geom2"]["point"]) + @responses.activate def test_geo_interface(self): dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/1" @@ -513,6 +540,57 @@ def __geo_interface__(self): actual = [json.loads(item) for item in snoop["payload"]] self.assertEqual(expected, actual) + @responses.activate + def test_from_geo_features_geo_attr(self): + def update_callback(request, snoop): + snoop["payload"] = request.body + return 200, {}, "{}" + + dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/1" + responses.add(responses.GET, dataset_url, json=self._dataset_json) + + # Create a dataset with multiple geometry attributes + multi_geo_attrs = deepcopy(self._attributes_json) + geo2_attr = deepcopy(multi_geo_attrs[-1]) + geo2_attr["name"] = "geom2" + multi_geo_attrs.append(geo2_attr) + attributes_url = f"{dataset_url}/attributes" + responses.add(responses.GET, attributes_url, json=multi_geo_attrs) + + records_url = f"{dataset_url}:updateRecords" + snoop = {} + responses.add_callback( + responses.POST, records_url, callback=partial(update_callback, snoop=snoop) + ) + + dataset = self.unify.datasets.by_resource_id("1") + features = [{"id": "1", "geometry": {"type": "Point", "coordinates": [0, 0]}}] + + # by default, the first attribute with geometry type is used for geometry + dataset.from_geo_features(features) + expected = [ + { + "action": "CREATE", + "recordId": "1", + "record": {"geom": {"point": [0, 0]}, "id": "1"}, + } + ] + actual = [json.loads(item) for item in snoop["payload"]] + self.assertEqual(expected, actual) + + # We can override which geometry attribute is used for geometry + snoop["payload"] = None + dataset.from_geo_features(features, geo_attr="geom2") + expected = [ + { + "action": "CREATE", + "recordId": "1", + "record": {"geom2": {"point": [0, 0]}, "id": "1"}, + } + ] + actual = [json.loads(item) for item in snoop["payload"]] + self.assertEqual(expected, actual) + @responses.activate def test_from_geo_features_composite_key(self): def update_callback(request, snoop): From c010eff737244e7a62a21da61c0dec30bd6db306 Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Tue, 25 Jun 2019 16:33:42 -0400 Subject: [PATCH 033/632] Binning model class file. --- tamr_unify_client/models/binning_model.py | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tamr_unify_client/models/binning_model.py diff --git a/tamr_unify_client/models/binning_model.py b/tamr_unify_client/models/binning_model.py new file mode 100644 index 00000000..d2cf22fd --- /dev/null +++ b/tamr_unify_client/models/binning_model.py @@ -0,0 +1,28 @@ +import json + +from tamr_unify_client.models.base_resource import BaseResource + + +class BinningModel(BaseResource): + """ A binning model object.""" + + @classmethod + def from_json(cls, client, resource_json, api_path=None): + return super().from_data(client, resource_json, api_path) + + def records(self): + """Stream this object's records as Python dictionaries. + + :return: Stream of records. + :rtype: Python generator yielding :py:class:`dict` + """ + with self.client.get(self.api_path + "/records", stream=True) as response: + for line in response.iter_lines(): + yield json.loads(line) + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"api_path={self.api_path})" + ) From 2dc55f191c6809bde6e9246cbb6f50830774cf41 Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Wed, 26 Jun 2019 14:02:13 -0400 Subject: [PATCH 034/632] Binning model support test file. --- tests/unit/test_binning_model.py | 52 ++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 tests/unit/test_binning_model.py diff --git a/tests/unit/test_binning_model.py b/tests/unit/test_binning_model.py new file mode 100644 index 00000000..0594dbb6 --- /dev/null +++ b/tests/unit/test_binning_model.py @@ -0,0 +1,52 @@ +import json + +import responses + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth + + +@responses.activate +def test_binning_model(): + + project_config = { + "name": "Project 1", + "description": "Mastering Project", + "type": "DEDUP", + "unifiedDatasetName": "Project 1 - Unified Dataset", + "externalId": "Project1", + "resourceId": "1", + } + + records_body = [ + { + "id": ["d8b7351d-24ce-49aa-8655-5b5809ab6bb8"], + "isActive": ["true"], + "clauseId": ["2e6c5f1b-ed49-40ab-8cbb-350aded25070"], + "similarityFunction": ["COSINE"], + "tokenizer": ["DEFAULT"], + "fieldName": ["surname"], + "threshold": ["0.75"], + } + ] + + records_url = ( + f"http://localhost:9100/api/versioned/v1/projects/1/binningModel/records" + ) + project_url = f"http://localhost:9100/api/versioned/v1/projects/1" + + responses.add(responses.GET, project_url, json=project_config) + + responses.add( + responses.GET, + records_url, + body="\n".join(json.dumps(body) for body in records_body), + ) + + unify = Client(UsernamePasswordAuth("username", "password")) + + project = unify.projects.by_resource_id("1").as_mastering() + binning_model = project.binning_model() + + binning_model_records = list(binning_model.records()) + assert binning_model_records == records_body From a1e3b6f667f2cad4ed1193c721044402252a3996 Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Tue, 2 Jul 2019 11:00:58 -0400 Subject: [PATCH 035/632] Updated changelog. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 243c694f..e0447daf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ **NEW FEATURES** - [#65](https://github.com/Datatamer/unify-client-python/issues/65) Fetches published clusters with data represented as a dataset. + - [#155](https://github.com/Datatamer/unify-client-python/issues/155) Adds read-only support for binning model. **BUG FIXES** - [#148](https://github.com/Datatamer/unify-client-python/issues/148) Fix null geo, id and absent id bug for geospatial datasets. From 1bfcb9d9a4dc3810603324a0b280daa275743f8d Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Mon, 1 Jul 2019 17:57:26 -0400 Subject: [PATCH 036/632] Added binning_model() method to mastering project. --- tamr_unify_client/models/project/mastering.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tamr_unify_client/models/project/mastering.py b/tamr_unify_client/models/project/mastering.py index f8543a00..574d8f67 100644 --- a/tamr_unify_client/models/project/mastering.py +++ b/tamr_unify_client/models/project/mastering.py @@ -1,3 +1,4 @@ +from tamr_unify_client.models.binning_model import BinningModel from tamr_unify_client.models.dataset.resource import Dataset from tamr_unify_client.models.machine_learning_model import MachineLearningModel from tamr_unify_client.models.project.estimated_pair_counts import EstimatedPairCounts @@ -121,3 +122,16 @@ def published_clusters_with_data(self): unified_dataset = self.unified_dataset() name = unified_dataset.name + "_dedup_published_clusters_with_data" return self.client.datasets.by_name(name) + + def binning_model(self): + """ + Binning model for this project. + + :return: Binning model for this project. + :rtype: :class:`~tamr_unify_client.models.binning_model.BinningModel` + """ + alias = self.api_path + "/binningModel" + + # Cannot get this resource and so we hard code + resource_json = {"relativeId": alias} + return BinningModel.from_json(self.client, resource_json, alias) From eaaee9cd499d079cba4f3fc1a199051f455f2f0c Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Wed, 3 Jul 2019 16:55:48 -0400 Subject: [PATCH 037/632] Update records method added to binning model. --- tamr_unify_client/models/binning_model.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tamr_unify_client/models/binning_model.py b/tamr_unify_client/models/binning_model.py index d2cf22fd..8d367296 100644 --- a/tamr_unify_client/models/binning_model.py +++ b/tamr_unify_client/models/binning_model.py @@ -20,6 +20,29 @@ def records(self): for line in response.iter_lines(): yield json.loads(line) + def update_records(self, records): + """Send a batch of record creations/updates/deletions to this dataset. + + :param records: Each record should be formatted as specified in the `Public Docs for Dataset updates `_. + :type records: iterable[dict] + :returns: JSON response body from server. + :rtype: :py:class:`dict` + """ + + def _stringify_updates(updates): + for update in updates: + yield json.dumps(update).encode("utf-8") + + return ( + self.client.post( + self.api_path + "/records", + headers={"Content-Encoding": "utf-8"}, + data=_stringify_updates(records), + ) + .successful() + .json() + ) + def __repr__(self): return ( f"{self.__class__.__module__}." From 7ccc6b5855e1e04fd9e2d1fc1fd116680b023d73 Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Wed, 3 Jul 2019 16:56:57 -0400 Subject: [PATCH 038/632] Testing update records. --- tests/unit/test_binning_model.py | 132 ++++++++++++++++++++++++++++--- 1 file changed, 121 insertions(+), 11 deletions(-) diff --git a/tests/unit/test_binning_model.py b/tests/unit/test_binning_model.py index 0594dbb6..e1ab0efc 100644 --- a/tests/unit/test_binning_model.py +++ b/tests/unit/test_binning_model.py @@ -1,3 +1,4 @@ +from functools import partial import json import responses @@ -6,17 +7,19 @@ from tamr_unify_client.auth import UsernamePasswordAuth -@responses.activate -def test_binning_model(): +project_config = { + "name": "Project 1", + "description": "Mastering Project", + "type": "DEDUP", + "unifiedDatasetName": "Project 1 - Unified Dataset", + "externalId": "Project1", + "resourceId": "1", +} +project_url = f"http://localhost:9100/api/versioned/v1/projects/1" + - project_config = { - "name": "Project 1", - "description": "Mastering Project", - "type": "DEDUP", - "unifiedDatasetName": "Project 1 - Unified Dataset", - "externalId": "Project1", - "resourceId": "1", - } +@responses.activate +def test_binning_model_records(): records_body = [ { @@ -33,7 +36,6 @@ def test_binning_model(): records_url = ( f"http://localhost:9100/api/versioned/v1/projects/1/binningModel/records" ) - project_url = f"http://localhost:9100/api/versioned/v1/projects/1" responses.add(responses.GET, project_url, json=project_config) @@ -50,3 +52,111 @@ def test_binning_model(): binning_model_records = list(binning_model.records()) assert binning_model_records == records_body + + +@responses.activate +def test_binning_model_update_records(): + + records_body = [ + { + "id": ["d8b7351d-24ce-49aa-8655-5b5809ab6bb8"], + "isActive": ["true"], + "clauseId": ["2e6c5f1b-ed49-40ab-8cbb-350aded25070"], + "similarityFunction": ["COSINE"], + "tokenizer": ["DEFAULT"], + "fieldName": ["surname"], + "threshold": ["0.75"], + }, + { + "id": ["d8b7351d-24ce-49aa-8655-5b5809ab6bc9"], + "isActive": ["true"], + "clauseId": ["2e6c5f1b-ed49-40ab-8cbb-350aded25070"], + "similarityFunction": ["COSINE"], + "tokenizer": ["DEFAULT"], + "fieldName": ["surname"], + "threshold": ["0.75"], + }, + { + "id": ["d8b7351d-24ce-49aa-8655-5b5809ab6bd8"], + "isActive": ["true"], + "clauseId": ["2e6c5f1b-ed49-40ab-8cbb-350aded25070"], + "similarityFunction": ["COSINE"], + "tokenizer": ["DEFAULT"], + "fieldName": ["surname"], + "threshold": ["0.75"], + }, + ] + + expected_updates = [ + { + "action": "CREATE", + "recordId": "d8b7351d-24ce-49aa-8655-5b5809ab6bb8", + "record": { + "id": ["d8b7351d-24ce-49aa-8655-5b5809ab6bb8"], + "isActive": ["true"], + "clauseId": ["2e6c5f1b-ed49-40ab-8cbb-350aded25070"], + "similarityFunction": ["COSINE"], + "tokenizer": ["DEFAULT"], + "fieldName": ["surname"], + "threshold": ["0.75"], + }, + }, + { + "action": "CREATE", + "recordId": "d8b7351d-24ce-49aa-8655-5b5809ab6bc9", + "record": { + "id": ["d8b7351d-24ce-49aa-8655-5b5809ab6bc9"], + "isActive": ["true"], + "clauseId": ["2e6c5f1b-ed49-40ab-8cbb-350aded25070"], + "similarityFunction": ["COSINE"], + "tokenizer": ["DEFAULT"], + "fieldName": ["surname"], + "threshold": ["0.75"], + }, + }, + { + "action": "CREATE", + "recordId": "d8b7351d-24ce-49aa-8655-5b5809ab6bd8", + "record": { + "id": ["d8b7351d-24ce-49aa-8655-5b5809ab6bd8"], + "isActive": ["true"], + "clauseId": ["2e6c5f1b-ed49-40ab-8cbb-350aded25070"], + "similarityFunction": ["COSINE"], + "tokenizer": ["DEFAULT"], + "fieldName": ["surname"], + "threshold": ["0.75"], + }, + }, + ] + + snoop_dict = {} + + def update_callback(request, snoop): + snoop["payload"] = request.body + return 200, {}, "{}" + + update_records_url = ( + f"http://localhost:9100/api/versioned/v1/projects/1/binningModel/records" + ) + + responses.add(responses.GET, project_url, json=project_config) + + responses.add_callback( + responses.POST, + update_records_url, + callback=partial(update_callback, snoop=snoop_dict), + ) + + unify = Client(UsernamePasswordAuth("username", "password")) + + project = unify.projects.by_resource_id("1").as_mastering() + binning_model = project.binning_model() + + updates = [ + {"action": "CREATE", "recordId": record["id"][0], "record": record} + for record in records_body + ] + + binning_model.update_records(updates) + actual = [json.loads(item) for item in snoop_dict["payload"]] + assert expected_updates == actual From e264eaaac0243bb6aeb1502e72f564840622a28e Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Mon, 8 Jul 2019 10:38:53 -0400 Subject: [PATCH 039/632] Changelog. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9ab16ad..be4cd44e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - [#65](https://github.com/Datatamer/unify-client-python/issues/65) Fetches published clusters with data represented as a dataset. - [#155](https://github.com/Datatamer/unify-client-python/issues/155) Adds read-only support for binning model. - [#165](https://github.com/Datatamer/unify-client-python/issues/165) Add `geo_attr` parameter to `Dataset.itergeofeatures()` and `Dataset.from_geo_features()` + - [#113](https://github.com/Datatamer/unify-client-python/issues/113) Add support for uploading a binningModel **BUG FIXES** - [#148](https://github.com/Datatamer/unify-client-python/issues/148) Fix null geo, id and absent id bug for geospatial datasets. From 722dc3db10a9873ab93e050123221cfb829069b0 Mon Sep 17 00:00:00 2001 From: Nik Bates-Haus Date: Tue, 9 Jul 2019 12:15:09 -0400 Subject: [PATCH 040/632] Prefactor: move test_project_add_dataset to test_project --- tests/unit/test_project.py | 84 ++++++++++++++++++++++++++ tests/unit/test_project_add_dataset.py | 81 ------------------------- 2 files changed, 84 insertions(+), 81 deletions(-) create mode 100644 tests/unit/test_project.py delete mode 100644 tests/unit/test_project_add_dataset.py diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py new file mode 100644 index 00000000..b9709940 --- /dev/null +++ b/tests/unit/test_project.py @@ -0,0 +1,84 @@ +from unittest import TestCase + +import responses + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth + + +class TestProject(TestCase): + @responses.activate + def test_project_add_source_dataset(self): + responses.add(responses.GET, self.datasets_url, json=self.dataset_json) + responses.add(responses.GET, self.projects_url, json=self.project_json) + responses.add( + responses.POST, + self.input_datasets_url, + json=self.post_input_datasets_json, + status=204, + ) + responses.add( + responses.GET, self.input_datasets_url, json=self.get_input_datasets_json + ) + auth = UsernamePasswordAuth("username", "password") + unify = Client(auth) + dataset = unify.datasets.by_external_id(self.dataset_external_id) + project = unify.projects.by_external_id(self.project_external_id) + project.add_source_dataset(dataset) + alias = project.api_path + "/inputDatasets" + input_datasets = project.client.get(alias).successful().json() + assert input_datasets == self.dataset_json + + dataset_external_id = "1" + datasets_url = f"http://localhost:9100/api/versioned/v1/datasets?filter=externalId=={dataset_external_id}" + dataset_json = [ + { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "1", + "name": "dataset 1 name", + "description": "dataset 1 description", + "version": "dataset 1 version", + "keyAttributeNames": ["tamr_id"], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version", + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version", + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [], + } + ] + project_json = [ + { + "id": "unify://unified-data/v1/projects/1", + "externalId": "1", + "name": "project 1 name", + "description": "project 1 description", + "type": "DEDUP", + "unifiedDatasetName": "project 1 unified dataset", + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "project 1 created version", + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "project 1 modified version", + }, + "relativeId": "projects/1", + } + ] + project_external_id = "1" + projects_url = f"http://localhost:9100/api/versioned/v1/projects?filter=externalId=={project_external_id}" + post_input_datasets_json = [] + input_datasets_url = ( + f"http://localhost:9100/api/versioned/v1/projects/1/inputDatasets" + ) + get_input_datasets_json = dataset_json diff --git a/tests/unit/test_project_add_dataset.py b/tests/unit/test_project_add_dataset.py deleted file mode 100644 index 444e1426..00000000 --- a/tests/unit/test_project_add_dataset.py +++ /dev/null @@ -1,81 +0,0 @@ -from functools import partial - -import pytest -import responses - -from tamr_unify_client import Client -from tamr_unify_client.auth import UsernamePasswordAuth - -dataset_json = [ - { - "id": "unify://unified-data/v1/datasets/1", - "externalId": "1", - "name": "dataset 1 name", - "description": "dataset 1 description", - "version": "dataset 1 version", - "keyAttributeNames": ["tamr_id"], - "tags": [], - "created": { - "username": "admin", - "time": "2018-09-10T16:06:20.636Z", - "version": "dataset 1 created version", - }, - "lastModified": { - "username": "admin", - "time": "2018-09-10T16:06:20.851Z", - "version": "dataset 1 modified version", - }, - "relativeId": "datasets/1", - "upstreamDatasetIds": [], - } -] - -dataset_external_id = "1" -datasets_url = f"http://localhost:9100/api/versioned/v1/datasets?filter=externalId=={dataset_external_id}" - -project_json = [ - { - "id": "unify://unified-data/v1/projects/1", - "externalId": "1", - "name": "project 1 name", - "description": "project 1 description", - "type": "DEDUP", - "unifiedDatasetName": "project 1 unified dataset", - "created": { - "username": "admin", - "time": "2018-09-10T16:06:20.636Z", - "version": "project 1 created version", - }, - "lastModified": { - "username": "admin", - "time": "2018-09-10T16:06:20.851Z", - "version": "project 1 modified version", - }, - "relativeId": "projects/1", - } -] - -project_external_id = "1" -projects_url = f"http://localhost:9100/api/versioned/v1/projects?filter=externalId=={project_external_id}" - -post_input_datasets_json = [] -input_datasets_url = f"http://localhost:9100/api/versioned/v1/projects/1/inputDatasets" -get_input_datasets_json = dataset_json - - -@responses.activate -def test_project_add_source_dataset(): - responses.add(responses.GET, datasets_url, json=dataset_json) - responses.add(responses.GET, projects_url, json=project_json) - responses.add( - responses.POST, input_datasets_url, json=post_input_datasets_json, status=204 - ) - responses.add(responses.GET, input_datasets_url, json=get_input_datasets_json) - auth = UsernamePasswordAuth("username", "password") - unify = Client(auth) - dataset = unify.datasets.by_external_id(dataset_external_id) - project = unify.projects.by_external_id(project_external_id) - project.add_source_dataset(dataset) - alias = project.api_path + "/inputDatasets" - input_datasets = project.client.get(alias).successful().json() - assert input_datasets == dataset_json From 5704506646219773422a438b324ae9e286fac1fb Mon Sep 17 00:00:00 2001 From: Nik Bates-Haus Date: Tue, 9 Jul 2019 11:39:42 -0400 Subject: [PATCH 041/632] Prefactor: move test_project_by_external_id to test_project --- tests/unit/test_project.py | 21 +++++++++- tests/unit/test_project_by_external_id.py | 50 ----------------------- 2 files changed, 19 insertions(+), 52 deletions(-) delete mode 100644 tests/unit/test_project_by_external_id.py diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index b9709940..d743587f 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -1,5 +1,6 @@ from unittest import TestCase +import pytest import responses from tamr_unify_client import Client @@ -29,6 +30,22 @@ def test_project_add_source_dataset(self): input_datasets = project.client.get(alias).successful().json() assert input_datasets == self.dataset_json + @responses.activate + def test_project_by_external_id__raises_when_not_found(self): + responses.add(responses.GET, self.projects_url, json=[]) + auth = UsernamePasswordAuth("username", "password") + unify = Client(auth) + with pytest.raises(KeyError): + unify.projects.by_external_id(self.project_external_id) + + @responses.activate + def test_project_by_external_id_succeeds(self): + responses.add(responses.GET, self.projects_url, json=self.project_json) + auth = UsernamePasswordAuth("username", "password") + unify = Client(auth) + actual_project = unify.projects.by_external_id(self.project_external_id) + assert actual_project._data == self.project_json[0] + dataset_external_id = "1" datasets_url = f"http://localhost:9100/api/versioned/v1/datasets?filter=externalId=={dataset_external_id}" dataset_json = [ @@ -57,7 +74,7 @@ def test_project_add_source_dataset(self): project_json = [ { "id": "unify://unified-data/v1/projects/1", - "externalId": "1", + "externalId": "project 1 external ID", "name": "project 1 name", "description": "project 1 description", "type": "DEDUP", @@ -75,7 +92,7 @@ def test_project_add_source_dataset(self): "relativeId": "projects/1", } ] - project_external_id = "1" + project_external_id = "project 1 external ID" projects_url = f"http://localhost:9100/api/versioned/v1/projects?filter=externalId=={project_external_id}" post_input_datasets_json = [] input_datasets_url = ( diff --git a/tests/unit/test_project_by_external_id.py b/tests/unit/test_project_by_external_id.py deleted file mode 100644 index 571c960c..00000000 --- a/tests/unit/test_project_by_external_id.py +++ /dev/null @@ -1,50 +0,0 @@ -import json - -import pytest -import responses - -from tamr_unify_client import Client -from tamr_unify_client.auth import UsernamePasswordAuth - -project_json = [ - { - "id": "unify://unified-data/v1/projects/1", - "externalId": "project 1 external ID", - "name": "project 1 name", - "description": "project 1 description", - "type": "DEDUP", - "unifiedDatasetName": "project 1 unified dataset", - "created": { - "username": "admin", - "time": "2018-09-10T16:06:20.636Z", - "version": "project 1 created version", - }, - "lastModified": { - "username": "admin", - "time": "2018-09-10T16:06:20.851Z", - "version": "project 1 modified version", - }, - "relativeId": "projects/1", - } -] - -project_external_id = "project 1 external ID" -projects_url = f"http://localhost:9100/api/versioned/v1/projects?filter=externalId=={project_external_id}" - - -@responses.activate -def test_project_by_external_id__raises_when_not_found(): - responses.add(responses.GET, projects_url, json=[]) - auth = UsernamePasswordAuth("username", "password") - unify = Client(auth) - with pytest.raises(KeyError): - unify.projects.by_external_id(project_external_id) - - -@responses.activate -def test_project_by_external_id_succeeds(): - responses.add(responses.GET, projects_url, json=project_json) - auth = UsernamePasswordAuth("username", "password") - unify = Client(auth) - actual_project = unify.projects.by_external_id(project_external_id) - assert actual_project._data == project_json[0] From 1995e10a42ff9013d65afa9a418e98cbb47cae48 Mon Sep 17 00:00:00 2001 From: Nik Bates-Haus Date: Tue, 9 Jul 2019 11:40:27 -0400 Subject: [PATCH 042/632] Prefactor: use unittest assertions instead of pytest assertions --- tests/unit/test_project.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index d743587f..be11e2be 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -1,6 +1,5 @@ from unittest import TestCase -import pytest import responses from tamr_unify_client import Client @@ -28,14 +27,14 @@ def test_project_add_source_dataset(self): project.add_source_dataset(dataset) alias = project.api_path + "/inputDatasets" input_datasets = project.client.get(alias).successful().json() - assert input_datasets == self.dataset_json + self.assertEqual(self.dataset_json, input_datasets) @responses.activate def test_project_by_external_id__raises_when_not_found(self): responses.add(responses.GET, self.projects_url, json=[]) auth = UsernamePasswordAuth("username", "password") unify = Client(auth) - with pytest.raises(KeyError): + with self.assertRaises(KeyError): unify.projects.by_external_id(self.project_external_id) @responses.activate @@ -44,7 +43,7 @@ def test_project_by_external_id_succeeds(self): auth = UsernamePasswordAuth("username", "password") unify = Client(auth) actual_project = unify.projects.by_external_id(self.project_external_id) - assert actual_project._data == self.project_json[0] + self.assertEqual(self.project_json[0], actual_project._data) dataset_external_id = "1" datasets_url = f"http://localhost:9100/api/versioned/v1/datasets?filter=externalId=={dataset_external_id}" From 3ea0d5065a95024990547dc1280497ece5ffbcb4 Mon Sep 17 00:00:00 2001 From: Nik Bates-Haus Date: Tue, 9 Jul 2019 12:15:37 -0400 Subject: [PATCH 043/632] Fix #168 - Add support for project attributes --- tamr_unify_client/models/project/resource.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tamr_unify_client/models/project/resource.py b/tamr_unify_client/models/project/resource.py index d8152858..94681bd6 100644 --- a/tamr_unify_client/models/project/resource.py +++ b/tamr_unify_client/models/project/resource.py @@ -36,6 +36,19 @@ def type(self): """ return self._data.get("type") + @property + def attributes(self): + """Attributes of this project. + + :return: Attributes of this project. + :rtype: :class:`~tamr_unify_client.models.attribute.collection.AttributeCollection` + """ + from tamr_unify_client.models.attribute.collection import AttributeCollection + + alias = self.api_path + "/attributes" + resource_json = self.client.get(alias).successful().json() + return AttributeCollection.from_json(self.client, resource_json, alias) + def unified_dataset(self): """Unified dataset for this project. From bbf3aecceb0ac4ac949eddfbb106434a719edfcb Mon Sep 17 00:00:00 2001 From: Nik Bates-Haus Date: Tue, 9 Jul 2019 11:34:00 -0400 Subject: [PATCH 044/632] Test #168 - Support for Project Attributes --- tests/unit/test_project.py | 60 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index be11e2be..7af1dd57 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -45,6 +45,42 @@ def test_project_by_external_id_succeeds(self): actual_project = unify.projects.by_external_id(self.project_external_id) self.assertEqual(self.project_json[0], actual_project._data) + @responses.activate + def test_project_attributes_get(self): + responses.add(responses.GET, self.projects_url, json=self.project_json) + responses.add( + responses.GET, + self.project_attributes_url, + json=self.project_attributes_json, + ) + unify = Client(UsernamePasswordAuth("username", "password")) + project = unify.projects.by_external_id(self.project_external_id) + attributes = list(project.attributes) + self.assertEqual(len(self.project_attributes_json), len(attributes)) + id_attribute = project.attributes.by_name("id") + self.assertEqual(self.project_attributes_json[0]["name"], id_attribute.name) + + @responses.activate + def test_project_attributes_post(self): + responses.add(responses.GET, self.projects_url, json=self.project_json) + responses.add( + responses.GET, + self.project_attributes_url, + json=self.project_attributes_json, + ) + responses.add( + responses.POST, + self.project_attributes_url, + json=self.project_attributes_json[0], + status=204, + ) + unify = Client(UsernamePasswordAuth("username", "password")) + project = unify.projects.by_external_id(self.project_external_id) + # project.attributes.create MUST make a POST request to self.project_attributes_url + # If it posts to some other URL, responses will raise an exception; + # If it does not post to any URL, responses will also raise an exception. + project.attributes.create(self.project_attributes_json[0]) + dataset_external_id = "1" datasets_url = f"http://localhost:9100/api/versioned/v1/datasets?filter=externalId=={dataset_external_id}" dataset_json = [ @@ -98,3 +134,27 @@ def test_project_by_external_id_succeeds(self): f"http://localhost:9100/api/versioned/v1/projects/1/inputDatasets" ) get_input_datasets_json = dataset_json + + project_attributes_url = ( + "http://localhost:9100/api/versioned/v1/projects/1/attributes" + ) + project_attributes_json = [ + { + "name": "id", + "description": "identifier", + "type": {"baseType": "STRING"}, + "isNullable": False, + }, + { + "name": "name", + "description": "full name", + "type": {"baseType": "ARRAY", "innerType": {"baseType": "STRING"}}, + "isNullable": True, + }, + { + "name": "description", + "description": "human readable description", + "type": {"baseType": "ARRAY", "innerType": {"baseType": "STRING"}}, + "isNullable": True, + }, + ] From 9be94e76581c260848db8e780265b954c4a6c6f3 Mon Sep 17 00:00:00 2001 From: Nik Bates-Haus Date: Tue, 9 Jul 2019 11:34:52 -0400 Subject: [PATCH 045/632] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index be4cd44e..1153d956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - [#155](https://github.com/Datatamer/unify-client-python/issues/155) Adds read-only support for binning model. - [#165](https://github.com/Datatamer/unify-client-python/issues/165) Add `geo_attr` parameter to `Dataset.itergeofeatures()` and `Dataset.from_geo_features()` - [#113](https://github.com/Datatamer/unify-client-python/issues/113) Add support for uploading a binningModel + - [#168](https://github.com/Datatamer/unify-client-python/issues/168) Add support for project attributes **BUG FIXES** - [#148](https://github.com/Datatamer/unify-client-python/issues/148) Fix null geo, id and absent id bug for geospatial datasets. From b38d2d69e5ee765ecc34b3c98ff2e0c8b0eb9ece Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 12 Jul 2019 10:34:32 -0400 Subject: [PATCH 046/632] Initial taxonomy functionality --- tamr_unify_client/models/taxonomy/resource.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tamr_unify_client/models/taxonomy/resource.py diff --git a/tamr_unify_client/models/taxonomy/resource.py b/tamr_unify_client/models/taxonomy/resource.py new file mode 100644 index 00000000..e69de29b From b30d94e20f572c8786608655a9ab1d32ca235452 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 12 Jul 2019 10:43:46 -0400 Subject: [PATCH 047/632] create and get taxonomy --- .../models/project/categorization.py | 27 +++++++++++++++++++ tamr_unify_client/models/taxonomy/resource.py | 9 +++++++ 2 files changed, 36 insertions(+) diff --git a/tamr_unify_client/models/project/categorization.py b/tamr_unify_client/models/project/categorization.py index 469bddd6..bcb9382a 100644 --- a/tamr_unify_client/models/project/categorization.py +++ b/tamr_unify_client/models/project/categorization.py @@ -1,5 +1,6 @@ from tamr_unify_client.models.machine_learning_model import MachineLearningModel from tamr_unify_client.models.project.resource import Project +from tamr_unify_client.models.taxonomy.resource import Taxonomy class CategorizationProject(Project): @@ -15,4 +16,30 @@ def model(self): alias = self.api_path + "/categorizations/model" return MachineLearningModel(self.client, None, alias) + def create_taxonomy(self, name=""): + """Creates a Taxonomy for this Categorization project. + + A taxonomy cannot already be associated with this project. + + :returns: The new Taxonomy + :rtype: :class:`~tamr_unify_client.models.taxonomy.resource.Taxonomy + """ + alias = self.api_path + "/taxonomy" + resource_json = self.client.post( + alias, json={"name": name} + ).successful().json() + return Taxonomy.from_json(self.client, resource_json, alias) + + def taxonomy(self): + """Retrieves the Taxonomy associated with Categorization project. + + If a taxonomy is not already associated with this project, call create_taxonomy() first. + + :returns: The project's Taxonomy + :rtype: :class:`~tamr_unify_client.models.taxonomy.resource.Taxonomy + """ + alias = self.api_path + "/taxonomy" + resource_json = self.client.get(alias).successful().json() + return Taxonomy.from_json(self.client, resource_json, alias) + # super.__repr__ is sufficient diff --git a/tamr_unify_client/models/taxonomy/resource.py b/tamr_unify_client/models/taxonomy/resource.py index e69de29b..a33ea57b 100644 --- a/tamr_unify_client/models/taxonomy/resource.py +++ b/tamr_unify_client/models/taxonomy/resource.py @@ -0,0 +1,9 @@ +from tamr_unify_client.models.base_resource import BaseResource + + +class Taxonomy(BaseResource): + """A project's taxonomy""" + + @classmethod + def from_json(cls, client, data, api_path): + return super().from_data(client, data, api_path) From a913cb4799979aebd9f6eeb04f431d15141a8dac Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 12 Jul 2019 11:31:31 -0400 Subject: [PATCH 048/632] taxonomy test --- .../models/project/categorization.py | 4 +- tamr_unify_client/models/taxonomy/resource.py | 13 ++++ tests/unit/test_categorization.py | 62 +++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_categorization.py diff --git a/tamr_unify_client/models/project/categorization.py b/tamr_unify_client/models/project/categorization.py index bcb9382a..e1cbc602 100644 --- a/tamr_unify_client/models/project/categorization.py +++ b/tamr_unify_client/models/project/categorization.py @@ -25,9 +25,7 @@ def create_taxonomy(self, name=""): :rtype: :class:`~tamr_unify_client.models.taxonomy.resource.Taxonomy """ alias = self.api_path + "/taxonomy" - resource_json = self.client.post( - alias, json={"name": name} - ).successful().json() + resource_json = self.client.post(alias, json={"name": name}).successful().json() return Taxonomy.from_json(self.client, resource_json, alias) def taxonomy(self): diff --git a/tamr_unify_client/models/taxonomy/resource.py b/tamr_unify_client/models/taxonomy/resource.py index a33ea57b..88176810 100644 --- a/tamr_unify_client/models/taxonomy/resource.py +++ b/tamr_unify_client/models/taxonomy/resource.py @@ -7,3 +7,16 @@ class Taxonomy(BaseResource): @classmethod def from_json(cls, client, data, api_path): return super().from_data(client, data, api_path) + + @property + def name(self): + """:type: str""" + return self._data.get("name") + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"relative_id={self.relative_id!r}, " + f"name={self.name!r})" + ) diff --git a/tests/unit/test_categorization.py b/tests/unit/test_categorization.py new file mode 100644 index 00000000..c7f516ba --- /dev/null +++ b/tests/unit/test_categorization.py @@ -0,0 +1,62 @@ +from unittest import TestCase + +import responses + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth + + +class TestCategorization(TestCase): + def setUp(self): + auth = UsernamePasswordAuth("username", "password") + self.unify = Client(auth) + + @responses.activate + def test_taxonomy(self): + project_url = f"http://localhost:9100/api/versioned/v1/projects/1" + taxonomy_url = f"http://localhost:9100/api/versioned/v1/projects/1/taxonomy" + responses.add(responses.GET, project_url, json=self._project_json) + responses.add(responses.POST, taxonomy_url, json=self._taxonomy_json) + + project = self.unify.projects.by_resource_id("1").as_categorization() + u = project.create_taxonomy("Test Taxonomy") + + responses.add(responses.GET, taxonomy_url, json=self._taxonomy_json) + t = project.taxonomy() + self.assertEqual(print(u), print(t)) + + _project_json = { + "id": "unify://unified-data/v1/projects/1", + "name": "Test Project", + "description": "Categorization Project", + "type": "CATEGORIZATION", + "unifiedDatasetName": "", + "created": { + "username": "admin", + "time": "2019-07-12T13:08:17.440Z", + "version": "401", + }, + "lastModified": { + "username": "admin", + "time": "2019-07-12T13:08:17.534Z", + "version": "402", + }, + "relativeId": "projects/1", + "externalId": "904bf89e-74ba-45c5-8b4a-5ff913728f66", + } + + _taxonomy_json = { + "id": "unify://unified-data/v1/projects/1/taxonomy", + "name": "Test Taxonomy", + "created": { + "username": "admin", + "time": "2019-07-12T13:09:14.981Z", + "version": "405", + }, + "lastModified": { + "username": "admin", + "time": "2019-07-12T13:09:14.981Z", + "version": "405", + }, + "relativeId": "projects/1/taxonomy", + } From dd5c60d703952e2c776760dd7fd972aa2086a959 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 16 Jul 2019 16:50:31 -0400 Subject: [PATCH 049/632] taxonomy creation spec --- tamr_unify_client/models/project/categorization.py | 10 ++++++---- tests/unit/test_categorization.py | 3 ++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/tamr_unify_client/models/project/categorization.py b/tamr_unify_client/models/project/categorization.py index e1cbc602..47ef6c9a 100644 --- a/tamr_unify_client/models/project/categorization.py +++ b/tamr_unify_client/models/project/categorization.py @@ -16,16 +16,18 @@ def model(self): alias = self.api_path + "/categorizations/model" return MachineLearningModel(self.client, None, alias) - def create_taxonomy(self, name=""): + def create_taxonomy(self, creation_spec): """Creates a Taxonomy for this Categorization project. A taxonomy cannot already be associated with this project. + :param creation_spec: The creation specification for the taxonomy, which can include name. + :type: dict :returns: The new Taxonomy - :rtype: :class:`~tamr_unify_client.models.taxonomy.resource.Taxonomy + :rtype: :class:`~tamr_unify_client.models.taxonomy.resource.Taxonomy` """ alias = self.api_path + "/taxonomy" - resource_json = self.client.post(alias, json={"name": name}).successful().json() + resource_json = self.client.post(alias, json=creation_spec).successful().json() return Taxonomy.from_json(self.client, resource_json, alias) def taxonomy(self): @@ -34,7 +36,7 @@ def taxonomy(self): If a taxonomy is not already associated with this project, call create_taxonomy() first. :returns: The project's Taxonomy - :rtype: :class:`~tamr_unify_client.models.taxonomy.resource.Taxonomy + :rtype: :class:`~tamr_unify_client.models.taxonomy.resource.Taxonomy` """ alias = self.api_path + "/taxonomy" resource_json = self.client.get(alias).successful().json() diff --git a/tests/unit/test_categorization.py b/tests/unit/test_categorization.py index c7f516ba..0bcb4ba6 100644 --- a/tests/unit/test_categorization.py +++ b/tests/unit/test_categorization.py @@ -19,7 +19,8 @@ def test_taxonomy(self): responses.add(responses.POST, taxonomy_url, json=self._taxonomy_json) project = self.unify.projects.by_resource_id("1").as_categorization() - u = project.create_taxonomy("Test Taxonomy") + creation_spec = {"name": "Test Taxonomy"} + u = project.create_taxonomy(creation_spec) responses.add(responses.GET, taxonomy_url, json=self._taxonomy_json) t = project.taxonomy() From a5bc574b12f50bc10dd24394a458f1c9902ce41c Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Wed, 17 Jul 2019 09:12:00 -0400 Subject: [PATCH 050/632] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1153d956..8eb0c57f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - [#165](https://github.com/Datatamer/unify-client-python/issues/165) Add `geo_attr` parameter to `Dataset.itergeofeatures()` and `Dataset.from_geo_features()` - [#113](https://github.com/Datatamer/unify-client-python/issues/113) Add support for uploading a binningModel - [#168](https://github.com/Datatamer/unify-client-python/issues/168) Add support for project attributes + - [#171](https://github.com/Datatamer/unify-client-python/issues/171) Add support for creating and retrieving taxonomies **BUG FIXES** - [#148](https://github.com/Datatamer/unify-client-python/issues/148) Fix null geo, id and absent id bug for geospatial datasets. From 62f3f1dddedd35dc86608045b91dd0513eb17f55 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Wed, 17 Jul 2019 10:39:46 -0400 Subject: [PATCH 051/632] get input datasets --- tamr_unify_client/models/project/resource.py | 10 ++++++++++ tests/unit/test_project.py | 9 +++++++++ 2 files changed, 19 insertions(+) diff --git a/tamr_unify_client/models/project/resource.py b/tamr_unify_client/models/project/resource.py index 94681bd6..3a9474ce 100644 --- a/tamr_unify_client/models/project/resource.py +++ b/tamr_unify_client/models/project/resource.py @@ -1,4 +1,5 @@ from tamr_unify_client.models.base_resource import BaseResource +from tamr_unify_client.models.dataset.collection import DatasetCollection from tamr_unify_client.models.dataset.resource import Dataset @@ -110,6 +111,15 @@ def add_source_dataset(self, dataset): ).successful() return response + def source_datasets(self): + """Retrieve a collection of this project's source datasets. + + :return: The project's input datasets. + :rtype: :class: `~tamr_unify_client.models.dataset.collection.DatasetCollection` + """ + alias = self.api_path + "/inputDatasets" + return DatasetCollection(self.client, alias) + def __repr__(self): return ( f"{self.__class__.__module__}." diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index 7af1dd57..06d16e25 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -4,6 +4,7 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth +from tamr_unify_client.models.project.resource import Project class TestProject(TestCase): @@ -81,6 +82,14 @@ def test_project_attributes_post(self): # If it does not post to any URL, responses will also raise an exception. project.attributes.create(self.project_attributes_json[0]) + def test_project_get_source_datasets(self): + auth = UsernamePasswordAuth("username", "password") + unify = Client(auth) + + p = Project(unify, self.project_json[0]) + datasets = p.source_datasets() + self.assertEqual(datasets.api_path, "projects/1/inputDatasets") + dataset_external_id = "1" datasets_url = f"http://localhost:9100/api/versioned/v1/datasets?filter=externalId=={dataset_external_id}" dataset_json = [ From 4734f08c6547db5ed12112b9df47a3b14b71cd82 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Wed, 17 Jul 2019 10:44:29 -0400 Subject: [PATCH 052/632] Cleaning up duplicate code in project tests --- tests/unit/test_project.py | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index 06d16e25..d3ed21a3 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -8,6 +8,10 @@ class TestProject(TestCase): + def setUp(self): + auth = UsernamePasswordAuth("username", "password") + self.unify = Client(auth) + @responses.activate def test_project_add_source_dataset(self): responses.add(responses.GET, self.datasets_url, json=self.dataset_json) @@ -21,10 +25,9 @@ def test_project_add_source_dataset(self): responses.add( responses.GET, self.input_datasets_url, json=self.get_input_datasets_json ) - auth = UsernamePasswordAuth("username", "password") - unify = Client(auth) - dataset = unify.datasets.by_external_id(self.dataset_external_id) - project = unify.projects.by_external_id(self.project_external_id) + + dataset = self.unify.datasets.by_external_id(self.dataset_external_id) + project = self.unify.projects.by_external_id(self.project_external_id) project.add_source_dataset(dataset) alias = project.api_path + "/inputDatasets" input_datasets = project.client.get(alias).successful().json() @@ -33,17 +36,13 @@ def test_project_add_source_dataset(self): @responses.activate def test_project_by_external_id__raises_when_not_found(self): responses.add(responses.GET, self.projects_url, json=[]) - auth = UsernamePasswordAuth("username", "password") - unify = Client(auth) with self.assertRaises(KeyError): - unify.projects.by_external_id(self.project_external_id) + self.unify.projects.by_external_id(self.project_external_id) @responses.activate def test_project_by_external_id_succeeds(self): responses.add(responses.GET, self.projects_url, json=self.project_json) - auth = UsernamePasswordAuth("username", "password") - unify = Client(auth) - actual_project = unify.projects.by_external_id(self.project_external_id) + actual_project = self.unify.projects.by_external_id(self.project_external_id) self.assertEqual(self.project_json[0], actual_project._data) @responses.activate @@ -54,8 +53,7 @@ def test_project_attributes_get(self): self.project_attributes_url, json=self.project_attributes_json, ) - unify = Client(UsernamePasswordAuth("username", "password")) - project = unify.projects.by_external_id(self.project_external_id) + project = self.unify.projects.by_external_id(self.project_external_id) attributes = list(project.attributes) self.assertEqual(len(self.project_attributes_json), len(attributes)) id_attribute = project.attributes.by_name("id") @@ -75,18 +73,14 @@ def test_project_attributes_post(self): json=self.project_attributes_json[0], status=204, ) - unify = Client(UsernamePasswordAuth("username", "password")) - project = unify.projects.by_external_id(self.project_external_id) + project = self.unify.projects.by_external_id(self.project_external_id) # project.attributes.create MUST make a POST request to self.project_attributes_url # If it posts to some other URL, responses will raise an exception; # If it does not post to any URL, responses will also raise an exception. project.attributes.create(self.project_attributes_json[0]) def test_project_get_source_datasets(self): - auth = UsernamePasswordAuth("username", "password") - unify = Client(auth) - - p = Project(unify, self.project_json[0]) + p = Project(self.unify, self.project_json[0]) datasets = p.source_datasets() self.assertEqual(datasets.api_path, "projects/1/inputDatasets") From 7259b3691086f53845b99368d029f5d6a4e7fafe Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Wed, 17 Jul 2019 14:50:35 -0400 Subject: [PATCH 053/632] changelog for input dataset --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eb0c57f..5a6286b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - [#113](https://github.com/Datatamer/unify-client-python/issues/113) Add support for uploading a binningModel - [#168](https://github.com/Datatamer/unify-client-python/issues/168) Add support for project attributes - [#171](https://github.com/Datatamer/unify-client-python/issues/171) Add support for creating and retrieving taxonomies + - [#178](https://github.com/Datatamer/unify-client-python/issues/178) Add support for retrieving input datasets **BUG FIXES** - [#148](https://github.com/Datatamer/unify-client-python/issues/148) Fix null geo, id and absent id bug for geospatial datasets. From 23e5d27ea8100f759bdae00ab7019ed57df860a2 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 18 Jul 2019 10:45:17 -0400 Subject: [PATCH 054/632] change source dataset to input dataset --- CHANGELOG.md | 1 + tamr_unify_client/models/project/resource.py | 6 +++--- tests/unit/test_project.py | 8 ++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a6286b2..20a84fd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ **BREAKING CHANGES** - [#156](https://github.com/Datatamer/unify-client-python/issues/156) Fetch Dataset profile, even if out of date. - [#161](https://github.com/Datatamer/unify-client-python/issues/161) Move `create_attribute` from Dataset to AttributeCollection + - The `Project` method `add_source_dataset` has been renamed `add_input_dataset` to model the API endpoint. **NEW FEATURES** - [#65](https://github.com/Datatamer/unify-client-python/issues/65) Fetches published clusters with data represented as a dataset. diff --git a/tamr_unify_client/models/project/resource.py b/tamr_unify_client/models/project/resource.py index 3a9474ce..ca8a95b8 100644 --- a/tamr_unify_client/models/project/resource.py +++ b/tamr_unify_client/models/project/resource.py @@ -92,7 +92,7 @@ def as_mastering(self): ) return MasteringProject(self.client, self._data, self.api_path) - def add_source_dataset(self, dataset): + def add_input_dataset(self, dataset): """ Associate a dataset with a project in Unify. @@ -111,8 +111,8 @@ def add_source_dataset(self, dataset): ).successful() return response - def source_datasets(self): - """Retrieve a collection of this project's source datasets. + def input_datasets(self): + """Retrieve a collection of this project's input datasets. :return: The project's input datasets. :rtype: :class: `~tamr_unify_client.models.dataset.collection.DatasetCollection` diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index d3ed21a3..da65bfbd 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -13,7 +13,7 @@ def setUp(self): self.unify = Client(auth) @responses.activate - def test_project_add_source_dataset(self): + def test_project_add_input_dataset(self): responses.add(responses.GET, self.datasets_url, json=self.dataset_json) responses.add(responses.GET, self.projects_url, json=self.project_json) responses.add( @@ -28,7 +28,7 @@ def test_project_add_source_dataset(self): dataset = self.unify.datasets.by_external_id(self.dataset_external_id) project = self.unify.projects.by_external_id(self.project_external_id) - project.add_source_dataset(dataset) + project.add_input_dataset(dataset) alias = project.api_path + "/inputDatasets" input_datasets = project.client.get(alias).successful().json() self.assertEqual(self.dataset_json, input_datasets) @@ -79,9 +79,9 @@ def test_project_attributes_post(self): # If it does not post to any URL, responses will also raise an exception. project.attributes.create(self.project_attributes_json[0]) - def test_project_get_source_datasets(self): + def test_project_get_input_datasets(self): p = Project(self.unify, self.project_json[0]) - datasets = p.source_datasets() + datasets = p.input_datasets() self.assertEqual(datasets.api_path, "projects/1/inputDatasets") dataset_external_id = "1" From b3c6d18dcf94d76461052836073a24e6d5d2558f Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Thu, 18 Jul 2019 11:43:09 -0400 Subject: [PATCH 055/632] Release 0.7.0 --- CHANGELOG.md | 8 +++++--- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20a84fd3..8fb98882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,9 @@ -## 0.7.0-dev +## 0.8.0-dev + +## 0.7.0 **BREAKING CHANGES** - [#156](https://github.com/Datatamer/unify-client-python/issues/156) Fetch Dataset profile, even if out of date. - - [#161](https://github.com/Datatamer/unify-client-python/issues/161) Move `create_attribute` from Dataset to AttributeCollection + - [#161](https://github.com/Datatamer/unify-client-python/issues/161) Move `create_attribute` from Dataset to AttributeCollection - The `Project` method `add_source_dataset` has been renamed `add_input_dataset` to model the API endpoint. **NEW FEATURES** @@ -14,7 +16,7 @@ - [#178](https://github.com/Datatamer/unify-client-python/issues/178) Add support for retrieving input datasets **BUG FIXES** - - [#148](https://github.com/Datatamer/unify-client-python/issues/148) Fix null geo, id and absent id bug for geospatial datasets. + - [#148](https://github.com/Datatamer/unify-client-python/issues/148) Fix null geo, id and absent id bug for geospatial datasets. - [#161](https://github.com/Datatamer/unify-client-python/issues/161) DatasetCollection.create() and ProjectCollection.create() don't work - [#165](https://github.com/Datatamer/unify-client-python/issues/165) Dataset.itergeofeatures() is too slow diff --git a/pyproject.toml b/pyproject.toml index 5c324227..a1cee138 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "tamr-unify-client" -version = "0.7.0-dev" +version = "0.8.0-dev" description = "Python Client for the Tamr Unify API" license = "Apache-2.0" authors = ["Pedro Cattori "] From bbf492ad461bee489422ebffb2330057caa396bc Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 12 Jul 2019 15:22:19 -0400 Subject: [PATCH 056/632] Category and collection --- tamr_unify_client/models/taxonomy/category.py | 49 ++++++++++ .../models/taxonomy/category_collection.py | 82 +++++++++++++++++ tamr_unify_client/models/taxonomy/resource.py | 10 +++ tests/unit/test_category.py | 89 +++++++++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 tamr_unify_client/models/taxonomy/category.py create mode 100644 tamr_unify_client/models/taxonomy/category_collection.py create mode 100644 tests/unit/test_category.py diff --git a/tamr_unify_client/models/taxonomy/category.py b/tamr_unify_client/models/taxonomy/category.py new file mode 100644 index 00000000..e8dc53c5 --- /dev/null +++ b/tamr_unify_client/models/taxonomy/category.py @@ -0,0 +1,49 @@ +from tamr_unify_client.models.base_resource import BaseResource + + +class Category(BaseResource): + """A category of a taxonomy""" + + @classmethod + def from_json(cls, client, data, api_path=None): + return super().from_data(client, data, api_path) + + @property + def name(self): + """:type: str""" + return self._data.get("name") + + @property + # note: this is broken in the api and will always be blank + def description(self): + """:type: str""" + return self._data.get("description") + + @property + def path(self): + """:type: list[str]""" + return self._data.get("path") + + def parent(self): + """Gets the parent Category of this one, or None if it is a tier 1 category + + :returns: The parent Category or None + :rtype: Category + """ + parent = self._data.get("parent") + if parent: + alias = self.api_path.rsplit("/", 1)[0] + "/" + parent.split("/")[-1] + resource_json = self.client.get(alias).successful().json() + return Category.from_json(self.client, resource_json, alias) + else: + return None + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"relative_id={self.relative_id!r}, " + f"name={self.name!r}," + f"path={'/'.join(self.path)!r}," + f"description={self.description!r})" + ) diff --git a/tamr_unify_client/models/taxonomy/category_collection.py b/tamr_unify_client/models/taxonomy/category_collection.py new file mode 100644 index 00000000..d49f55f7 --- /dev/null +++ b/tamr_unify_client/models/taxonomy/category_collection.py @@ -0,0 +1,82 @@ +from tamr_unify_client.models.base_collection import BaseCollection +from tamr_unify_client.models.taxonomy.category import Category + + +class CategoryCollection(BaseCollection): + """Collection of :class:`~tamr_unify_client.models.dataset.resource.Category` s. + + :param client: Client for API call delegation. + :type client: :class:`~tamr_unify_client.Client` + :param api_path: API path used to access this collection. + E.g. ``"projects/1/taxonomy/categories"``. + :type api_path: str + """ + + def __init__(self, client, api_path): + super().__init__(client, api_path) + + def by_resource_id(self, resource_id): + """Retrieve a category by resource ID. + + :param resource_id: The resource ID. E.g. ``"1"`` + :type resource_id: str + :returns: The specified category. + :rtype: :class:`~tamr_unify_client.models.taxonomy.resource.Category` + """ + return super().by_resource_id(self.api_path, resource_id) + + def by_relative_id(self, relative_id): + """Retrieve a category by relative ID. + + :param relative_id: The resource ID. E.g. ``"categories/1"`` or ``"projects/1/categories/1"`` + :type relative_id: str + :returns: The specified category. + :rtype: :class:`~tamr_unify_client.models.taxonomy.resource.Category` + """ + resource_id = relative_id.split("/")[-1] + return self.by_resource_id(resource_id) + + def by_external_id(self, external_id): + """Retrieve an attribute by external ID. + + Since categories do not have external IDs, this method is not supported and will + raise a :class:`NotImplementedError` . + + :param external_id: The external ID. + :type external_id: str + :returns: The specified category, if found. + :rtype: :class:`~tamr_unify_client.models.taxonomy.category.Category` + :raises KeyError: If no category with the specified external_id is found + :raises LookupError: If multiple categories with the specified external_id are found + """ + raise NotImplementedError("Categories do not have external_id") + + def stream(self): + """Stream categories in this collection. Implicitly called when iterating + over this collection. + + :returns: Stream of categories. + :rtype: Python generator yielding :class:`~tamr_unify_client.models.taxonomy.category.Category` + + Usage: + >>> for category in collection.stream(): # explicit + >>> do_stuff(category) + >>> for category in collection: # implicit + >>> do_stuff(category) + """ + return super().stream(Category) + + def create(self, creation_spec): + """ Creates a new category. + + :param creation_spec: + :type: dict + :return: The newly created category. + :rtype: :class:`~tamr_unify_client.models.taxonomy.resource.Category` + """ + resource_json = ( + self.client.post(self.api_path, json=creation_spec).successful().as_json() + ) + return Category.from_json(self.client, resource_json) + + # super.__repr__ is sufficient diff --git a/tamr_unify_client/models/taxonomy/resource.py b/tamr_unify_client/models/taxonomy/resource.py index 88176810..968ded5d 100644 --- a/tamr_unify_client/models/taxonomy/resource.py +++ b/tamr_unify_client/models/taxonomy/resource.py @@ -1,4 +1,5 @@ from tamr_unify_client.models.base_resource import BaseResource +from tamr_unify_client.models.taxonomy.category_collection import CategoryCollection class Taxonomy(BaseResource): @@ -13,6 +14,15 @@ def name(self): """:type: str""" return self._data.get("name") + def categories(self): + """Retrieves the categories of this taxonomy. + + :returns: A collection of the taxonomy categories. + :rtype: :class:'~tamr_unify_client.models.taxonomy.category_collection.CategoryCollection' + """ + alias = self.api_path + "/categories" + return CategoryCollection(self.client, alias) + def __repr__(self): return ( f"{self.__class__.__module__}." diff --git a/tests/unit/test_category.py b/tests/unit/test_category.py new file mode 100644 index 00000000..450cce52 --- /dev/null +++ b/tests/unit/test_category.py @@ -0,0 +1,89 @@ +from unittest import TestCase + +import responses + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth +from tamr_unify_client.models.taxonomy.category import Category + + +class TestCategory(TestCase): + def setUp(self): + auth = UsernamePasswordAuth("username", "password") + self.unify = Client(auth) + + def test_resource(self): + alias = "projects/1/taxonomy/categories/1" + row_num = Category(self.unify, self._categories_json[0], alias) + + expected = alias + self.assertEqual(expected, row_num.relative_id) + + expected = self._categories_json[0]["name"] + self.assertEqual(expected, row_num.name) + + expected = self._categories_json[0]["description"] + self.assertEqual(expected, row_num.description) + + def test_resource_from_json(self): + alias = "projects/1/taxonomy/categories/1" + expected = Category(self.unify, self._categories_json[0], alias) + actual = Category.from_json(self.unify, self._categories_json[0], alias) + self.assertEqual(repr(expected), repr(actual)) + + @responses.activate + def test_path(self): + t2 = Category( + self.unify, self._categories_json[1], "projects/1/taxonomy/categories/2" + ) + + parent_path = ( + "http://localhost:9100/api/versioned/v1/projects/1/taxonomy/categories/1" + ) + responses.add(responses.GET, parent_path, json=self._categories_json[0]) + t1 = t2.parent() + + self.assertEqual(self._categories_json[0]["relativeId"], t1.relative_id) + self.assertEqual(t1.parent(), None) + + self.assertEqual(t1.path, [t1.name]) + self.assertEqual(t2.path, [t1.name, t2.name]) + + _categories_json = [ + { + "id": "unify://unified-data/v1/projects/1/taxonomy/categories/1", + "name": "t1", + "description": "", + "parent": "", + "path": ["t1"], + "created": { + "username": "admin", + "time": "2019-07-12T13:10:52.988Z", + "version": "414", + }, + "lastModified": { + "username": "admin", + "time": "2019-07-12T13:10:52.988Z", + "version": "414", + }, + "relativeId": "projects/1/taxonomy/categories/1", + }, + { + "id": "unify://unified-data/v1/projects/1/taxonomy/categories/2", + "name": "t2", + "description": "", + "parent": "unify://unified-data/v1/projects/1/taxonomy/categories/1", + "path": ["t1", "t2"], + "created": { + "username": "admin", + "time": "2019-07-12T13:51:20.600Z", + "version": "419", + }, + "lastModified": { + "username": "admin", + "time": "2019-07-12T13:51:20.600Z", + "version": "419", + }, + "relativeId": "projects/1/taxonomy/categories/2", + }, + ] From b719adb77d0a08bb10b6bab3f3e212a0eb1384cb Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Mon, 15 Jul 2019 11:52:56 -0400 Subject: [PATCH 057/632] testing --- .../models/taxonomy/category_collection.py | 11 +- tests/unit/test_category.py | 4 +- tests/unit/test_taxonomy.py | 117 ++++++++++++++++++ 3 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 tests/unit/test_taxonomy.py diff --git a/tamr_unify_client/models/taxonomy/category_collection.py b/tamr_unify_client/models/taxonomy/category_collection.py index d49f55f7..21c54d4d 100644 --- a/tamr_unify_client/models/taxonomy/category_collection.py +++ b/tamr_unify_client/models/taxonomy/category_collection.py @@ -28,13 +28,12 @@ def by_resource_id(self, resource_id): def by_relative_id(self, relative_id): """Retrieve a category by relative ID. - :param relative_id: The resource ID. E.g. ``"categories/1"`` or ``"projects/1/categories/1"`` + :param relative_id: The relative ID. E.g. ``"projects/1/categories/1"`` :type relative_id: str :returns: The specified category. :rtype: :class:`~tamr_unify_client.models.taxonomy.resource.Category` """ - resource_id = relative_id.split("/")[-1] - return self.by_resource_id(resource_id) + return super().by_relative_id(Category, relative_id) def by_external_id(self, external_id): """Retrieve an attribute by external ID. @@ -69,13 +68,15 @@ def stream(self): def create(self, creation_spec): """ Creates a new category. - :param creation_spec: + :param creation_spec: Category creation specification. + It needs the following fields: name (str) and path (list[str]). + It can also have description (str) but that currently does nothing. :type: dict :return: The newly created category. :rtype: :class:`~tamr_unify_client.models.taxonomy.resource.Category` """ resource_json = ( - self.client.post(self.api_path, json=creation_spec).successful().as_json() + self.client.post(self.api_path, json=creation_spec).successful().json() ) return Category.from_json(self.client, resource_json) diff --git a/tests/unit/test_category.py b/tests/unit/test_category.py index 450cce52..dc1dc00e 100644 --- a/tests/unit/test_category.py +++ b/tests/unit/test_category.py @@ -37,10 +37,10 @@ def test_path(self): self.unify, self._categories_json[1], "projects/1/taxonomy/categories/2" ) - parent_path = ( + parent_url = ( "http://localhost:9100/api/versioned/v1/projects/1/taxonomy/categories/1" ) - responses.add(responses.GET, parent_path, json=self._categories_json[0]) + responses.add(responses.GET, parent_url, json=self._categories_json[0]) t1 = t2.parent() self.assertEqual(self._categories_json[0]["relativeId"], t1.relative_id) diff --git a/tests/unit/test_taxonomy.py b/tests/unit/test_taxonomy.py new file mode 100644 index 00000000..609249d9 --- /dev/null +++ b/tests/unit/test_taxonomy.py @@ -0,0 +1,117 @@ +from unittest import TestCase + +import responses + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth +from tamr_unify_client.models.taxonomy.category import Category +from tamr_unify_client.models.taxonomy.category_collection import CategoryCollection +from tamr_unify_client.models.taxonomy.resource import Taxonomy + + +class TestTaxonomy(TestCase): + def setUp(self): + auth = UsernamePasswordAuth("username", "password") + self.unify = Client(auth) + + @responses.activate + def test_categories(self): + cat_url = ( + "http://localhost:9100/api/versioned/v1/projects/1/taxonomy/categories" + ) + responses.add(responses.GET, cat_url, json=self._categories_json) + + t = Taxonomy(self.unify, self._taxonomy_json) + c = list(t.categories()) + + cats = [ + Category(self.unify, self._categories_json[0]), + Category(self.unify, self._categories_json[1]), + ] + self.assertEqual(repr(c), repr(cats)) + + @responses.activate + def test_by_id(self): + cat_url = ( + "http://localhost:9100/api/versioned/v1/projects/1/taxonomy/categories/1" + ) + responses.add(responses.GET, cat_url, json=self._categories_json[0]) + + c = CategoryCollection(self.unify, "projects/1/taxonomy/categories") + r = c.by_relative_id("projects/1/taxonomy/categories/1") + self.assertEqual(r._data, self._categories_json[0]) + r = c.by_resource_id("1") + self.assertEqual(r._data, self._categories_json[0]) + self.assertRaises(NotImplementedError, c.by_external_id, "1") + + @responses.activate + def test_create(self): + post_url = ( + "http://localhost:9100/api/versioned/v1/projects/1/taxonomy/categories" + ) + responses.add(responses.POST, post_url, json=self._categories_json[0]) + + alias = "projects/1/taxonomy/categories" + coll = CategoryCollection(self.unify, alias) + + creation_spec = { + "name": self._categories_json[0]["name"], + "path": self._categories_json[0]["path"], + } + c = coll.create(creation_spec) + self.assertEqual(alias + "/1", c.relative_id) + + _taxonomy_json = { + "id": "unify://unified-data/v1/projects/1/taxonomy", + "name": "Test Taxonomy", + "created": { + "username": "admin", + "time": "2019-07-12T13:09:14.981Z", + "version": "405", + }, + "lastModified": { + "username": "admin", + "time": "2019-07-12T13:09:14.981Z", + "version": "405", + }, + "relativeId": "projects/1/taxonomy", + } + + _categories_json = [ + { + "id": "unify://unified-data/v1/projects/1/taxonomy/categories/1", + "name": "t1", + "description": "", + "parent": "", + "path": ["t1"], + "created": { + "username": "admin", + "time": "2019-07-12T13:10:52.988Z", + "version": "414", + }, + "lastModified": { + "username": "admin", + "time": "2019-07-12T13:10:52.988Z", + "version": "414", + }, + "relativeId": "projects/1/taxonomy/categories/1", + }, + { + "id": "unify://unified-data/v1/projects/1/taxonomy/categories/2", + "name": "t2", + "description": "", + "parent": "unify://unified-data/v1/projects/1/taxonomy/categories/1", + "path": ["t1", "t2"], + "created": { + "username": "admin", + "time": "2019-07-12T13:51:20.600Z", + "version": "419", + }, + "lastModified": { + "username": "admin", + "time": "2019-07-12T13:51:20.600Z", + "version": "419", + }, + "relativeId": "projects/1/taxonomy/categories/2", + }, + ] From b36c48be9a03fe4670484869a46b98d728e80947 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Wed, 17 Jul 2019 09:54:43 -0400 Subject: [PATCH 058/632] bulk upload categories --- .../models/taxonomy/category_collection.py | 20 +++++++++++++ tests/unit/test_taxonomy.py | 29 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/tamr_unify_client/models/taxonomy/category_collection.py b/tamr_unify_client/models/taxonomy/category_collection.py index 21c54d4d..6765a624 100644 --- a/tamr_unify_client/models/taxonomy/category_collection.py +++ b/tamr_unify_client/models/taxonomy/category_collection.py @@ -1,3 +1,5 @@ +import json + from tamr_unify_client.models.base_collection import BaseCollection from tamr_unify_client.models.taxonomy.category import Category @@ -80,4 +82,22 @@ def create(self, creation_spec): ) return Category.from_json(self.client, resource_json) + def bulk_create(self, creation_specs): + """Creates new categories in bulk. + + :param creation_specs: A collection of creation specifications, as detailed for create. + :type: iterable[dict] + :returns: JSON response from the server + """ + body = "\n".join([json.dumps(s) for s in creation_specs]).encode("utf-8") + return ( + self.client.post( + self.api_path + ":bulk", + headers={"Content-Encoding": "utf-8"}, + data=body, + ) + .successful() + .json() + ) + # super.__repr__ is sufficient diff --git a/tests/unit/test_taxonomy.py b/tests/unit/test_taxonomy.py index 609249d9..241f81b2 100644 --- a/tests/unit/test_taxonomy.py +++ b/tests/unit/test_taxonomy.py @@ -61,6 +61,29 @@ def test_create(self): c = coll.create(creation_spec) self.assertEqual(alias + "/1", c.relative_id) + @responses.activate + def test_bulk_create(self): + post_url = ( + "http://localhost:9100/api/versioned/v1/projects/1/taxonomy/categories:bulk" + ) + responses.add(responses.POST, post_url, json=self._bulk_json) + + alias = "projects/1/taxonomy/categories" + coll = CategoryCollection(self.unify, alias) + + creation_specs = [ + { + "name": self._categories_json[0]["name"], + "path": self._categories_json[0]["path"], + }, + { + "name": self._categories_json[1]["name"], + "path": self._categories_json[1]["path"], + }, + ] + j = coll.bulk_create(creation_specs) + self.assertEqual(j, self._bulk_json) + _taxonomy_json = { "id": "unify://unified-data/v1/projects/1/taxonomy", "name": "Test Taxonomy", @@ -115,3 +138,9 @@ def test_create(self): "relativeId": "projects/1/taxonomy/categories/2", }, ] + + _bulk_json = { + "numCommandsProcessed": 2, + "allCommandsSucceeded": True, + "validationErrors": [], + } From 4146f6f067c632f5f01a592adee528152889bc72 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Wed, 17 Jul 2019 14:04:07 -0400 Subject: [PATCH 059/632] Taxonomy docs --- docs/developer-interface.rst | 18 ++++++++++++++++++ tamr_unify_client/models/taxonomy/resource.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index 4dcf4d6e..84f296e9 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -13,6 +13,18 @@ Client .. autoclass:: tamr_unify_client.Client :members: +Category +-------- + +.. autoclass:: tamr_unify_client.models.taxonomy.category.Category + :members: + +Categories +---------- + +.. autoclass:: tamr_unify_client.models.taxonomy.category_collection.CategoryCollection + :members: + Dataset ------- @@ -91,3 +103,9 @@ Projects .. autoclass:: tamr_unify_client.models.project.collection.ProjectCollection :members: + +Taxonomy +-------- + +.. autoclass:: tamr_unify_client.models.taxonomy.resource.Taxonomy + :members: \ No newline at end of file diff --git a/tamr_unify_client/models/taxonomy/resource.py b/tamr_unify_client/models/taxonomy/resource.py index 968ded5d..fc3e9d57 100644 --- a/tamr_unify_client/models/taxonomy/resource.py +++ b/tamr_unify_client/models/taxonomy/resource.py @@ -18,7 +18,7 @@ def categories(self): """Retrieves the categories of this taxonomy. :returns: A collection of the taxonomy categories. - :rtype: :class:'~tamr_unify_client.models.taxonomy.category_collection.CategoryCollection' + :rtype: :class:`~tamr_unify_client.models.taxonomy.category_collection.CategoryCollection` """ alias = self.api_path + "/categories" return CategoryCollection(self.client, alias) From e114e476a320b005a614066acd42198beb282f61 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 18 Jul 2019 13:21:20 -0400 Subject: [PATCH 060/632] refactor category resource --- tamr_unify_client/models/category/__init__.py | 0 .../category_collection.py => category/collection.py} | 2 +- .../models/{taxonomy/category.py => category/resource.py} | 0 tamr_unify_client/models/taxonomy/__init__.py | 0 tamr_unify_client/models/taxonomy/resource.py | 2 +- tests/unit/test_category.py | 2 +- tests/unit/test_taxonomy.py | 4 ++-- 7 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 tamr_unify_client/models/category/__init__.py rename tamr_unify_client/models/{taxonomy/category_collection.py => category/collection.py} (98%) rename tamr_unify_client/models/{taxonomy/category.py => category/resource.py} (100%) create mode 100644 tamr_unify_client/models/taxonomy/__init__.py diff --git a/tamr_unify_client/models/category/__init__.py b/tamr_unify_client/models/category/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tamr_unify_client/models/taxonomy/category_collection.py b/tamr_unify_client/models/category/collection.py similarity index 98% rename from tamr_unify_client/models/taxonomy/category_collection.py rename to tamr_unify_client/models/category/collection.py index 6765a624..3be0e4c4 100644 --- a/tamr_unify_client/models/taxonomy/category_collection.py +++ b/tamr_unify_client/models/category/collection.py @@ -1,7 +1,7 @@ import json from tamr_unify_client.models.base_collection import BaseCollection -from tamr_unify_client.models.taxonomy.category import Category +from tamr_unify_client.models.category.resource import Category class CategoryCollection(BaseCollection): diff --git a/tamr_unify_client/models/taxonomy/category.py b/tamr_unify_client/models/category/resource.py similarity index 100% rename from tamr_unify_client/models/taxonomy/category.py rename to tamr_unify_client/models/category/resource.py diff --git a/tamr_unify_client/models/taxonomy/__init__.py b/tamr_unify_client/models/taxonomy/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tamr_unify_client/models/taxonomy/resource.py b/tamr_unify_client/models/taxonomy/resource.py index fc3e9d57..76ff511c 100644 --- a/tamr_unify_client/models/taxonomy/resource.py +++ b/tamr_unify_client/models/taxonomy/resource.py @@ -1,5 +1,5 @@ from tamr_unify_client.models.base_resource import BaseResource -from tamr_unify_client.models.taxonomy.category_collection import CategoryCollection +from tamr_unify_client.models.category.collection import CategoryCollection class Taxonomy(BaseResource): diff --git a/tests/unit/test_category.py b/tests/unit/test_category.py index dc1dc00e..4cc8ee90 100644 --- a/tests/unit/test_category.py +++ b/tests/unit/test_category.py @@ -4,7 +4,7 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth -from tamr_unify_client.models.taxonomy.category import Category +from tamr_unify_client.models.category.resource import Category class TestCategory(TestCase): diff --git a/tests/unit/test_taxonomy.py b/tests/unit/test_taxonomy.py index 241f81b2..aff67176 100644 --- a/tests/unit/test_taxonomy.py +++ b/tests/unit/test_taxonomy.py @@ -4,8 +4,8 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth -from tamr_unify_client.models.taxonomy.category import Category -from tamr_unify_client.models.taxonomy.category_collection import CategoryCollection +from tamr_unify_client.models.category.resource import Category +from tamr_unify_client.models.category.collection import CategoryCollection from tamr_unify_client.models.taxonomy.resource import Taxonomy From 88ffb5081100badd42704cd350ee07f8ac1381ff Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 18 Jul 2019 14:27:57 -0400 Subject: [PATCH 061/632] testing bulk request body --- tests/unit/test_taxonomy.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_taxonomy.py b/tests/unit/test_taxonomy.py index aff67176..7c0b0b9f 100644 --- a/tests/unit/test_taxonomy.py +++ b/tests/unit/test_taxonomy.py @@ -1,3 +1,5 @@ +from functools import partial +import json from unittest import TestCase import responses @@ -63,10 +65,15 @@ def test_create(self): @responses.activate def test_bulk_create(self): + def create_callback(request, snoop): + snoop["payload"] = request.body + return 200, {}, json.dumps(self._bulk_json) + post_url = ( "http://localhost:9100/api/versioned/v1/projects/1/taxonomy/categories:bulk" ) - responses.add(responses.POST, post_url, json=self._bulk_json) + snoop_dict = {} + responses.add_callback(responses.POST, post_url, partial(create_callback, snoop=snoop_dict)) alias = "projects/1/taxonomy/categories" coll = CategoryCollection(self.unify, alias) @@ -84,6 +91,11 @@ def test_bulk_create(self): j = coll.bulk_create(creation_specs) self.assertEqual(j, self._bulk_json) + sent = [] + for line in snoop_dict["payload"].split(b"\n"): + sent.append(json.loads(line)) + self.assertEqual(sent, creation_specs) + _taxonomy_json = { "id": "unify://unified-data/v1/projects/1/taxonomy", "name": "Test Taxonomy", From 4faef210469afdfec691b00ba749bc69cda1aa01 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 18 Jul 2019 14:31:15 -0400 Subject: [PATCH 062/632] update changelog and docs --- CHANGELOG.md | 2 ++ docs/developer-interface.rst | 4 ++-- tamr_unify_client/models/category/collection.py | 17 ++++++++--------- tamr_unify_client/models/category/resource.py | 1 - tamr_unify_client/models/taxonomy/resource.py | 2 +- tests/unit/test_category.py | 2 +- tests/unit/test_taxonomy.py | 6 ++++-- 7 files changed, 18 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fb98882..afe64fc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ ## 0.8.0-dev + **NEW FEATURES** + - [#174](https://github.com/Datatamer/unify-client-python/issues/174) Get and create taxonomy categories ## 0.7.0 **BREAKING CHANGES** diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index 84f296e9..8ccc038d 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -16,13 +16,13 @@ Client Category -------- -.. autoclass:: tamr_unify_client.models.taxonomy.category.Category +.. autoclass:: tamr_unify_client.models.category.resource.Category :members: Categories ---------- -.. autoclass:: tamr_unify_client.models.taxonomy.category_collection.CategoryCollection +.. autoclass:: tamr_unify_client.models.category.collection.CategoryCollection :members: Dataset diff --git a/tamr_unify_client/models/category/collection.py b/tamr_unify_client/models/category/collection.py index 3be0e4c4..5185efa8 100644 --- a/tamr_unify_client/models/category/collection.py +++ b/tamr_unify_client/models/category/collection.py @@ -5,7 +5,7 @@ class CategoryCollection(BaseCollection): - """Collection of :class:`~tamr_unify_client.models.dataset.resource.Category` s. + """Collection of :class:`~tamr_unify_client.models.category.resource.Category` s. :param client: Client for API call delegation. :type client: :class:`~tamr_unify_client.Client` @@ -23,7 +23,7 @@ def by_resource_id(self, resource_id): :param resource_id: The resource ID. E.g. ``"1"`` :type resource_id: str :returns: The specified category. - :rtype: :class:`~tamr_unify_client.models.taxonomy.resource.Category` + :rtype: :class:`~tamr_unify_client.models.category.resource.Category` """ return super().by_resource_id(self.api_path, resource_id) @@ -33,7 +33,7 @@ def by_relative_id(self, relative_id): :param relative_id: The relative ID. E.g. ``"projects/1/categories/1"`` :type relative_id: str :returns: The specified category. - :rtype: :class:`~tamr_unify_client.models.taxonomy.resource.Category` + :rtype: :class:`~tamr_unify_client.models.category.resource.Category` """ return super().by_relative_id(Category, relative_id) @@ -46,7 +46,7 @@ def by_external_id(self, external_id): :param external_id: The external ID. :type external_id: str :returns: The specified category, if found. - :rtype: :class:`~tamr_unify_client.models.taxonomy.category.Category` + :rtype: :class:`~tamr_unify_client.models.category.resource.Category` :raises KeyError: If no category with the specified external_id is found :raises LookupError: If multiple categories with the specified external_id are found """ @@ -57,7 +57,7 @@ def stream(self): over this collection. :returns: Stream of categories. - :rtype: Python generator yielding :class:`~tamr_unify_client.models.taxonomy.category.Category` + :rtype: Python generator yielding :class:`~tamr_unify_client.models.category.resource.Category` Usage: >>> for category in collection.stream(): # explicit @@ -70,12 +70,11 @@ def stream(self): def create(self, creation_spec): """ Creates a new category. - :param creation_spec: Category creation specification. - It needs the following fields: name (str) and path (list[str]). - It can also have description (str) but that currently does nothing. + :param creation_spec: Category creation specification, formatted as specified in the + `Public Docs for Creating a Category `_. :type: dict :return: The newly created category. - :rtype: :class:`~tamr_unify_client.models.taxonomy.resource.Category` + :rtype: :class:`~tamr_unify_client.models.category.resource.Category` """ resource_json = ( self.client.post(self.api_path, json=creation_spec).successful().json() diff --git a/tamr_unify_client/models/category/resource.py b/tamr_unify_client/models/category/resource.py index e8dc53c5..1989430d 100644 --- a/tamr_unify_client/models/category/resource.py +++ b/tamr_unify_client/models/category/resource.py @@ -14,7 +14,6 @@ def name(self): return self._data.get("name") @property - # note: this is broken in the api and will always be blank def description(self): """:type: str""" return self._data.get("description") diff --git a/tamr_unify_client/models/taxonomy/resource.py b/tamr_unify_client/models/taxonomy/resource.py index 76ff511c..5ec87527 100644 --- a/tamr_unify_client/models/taxonomy/resource.py +++ b/tamr_unify_client/models/taxonomy/resource.py @@ -18,7 +18,7 @@ def categories(self): """Retrieves the categories of this taxonomy. :returns: A collection of the taxonomy categories. - :rtype: :class:`~tamr_unify_client.models.taxonomy.category_collection.CategoryCollection` + :rtype: :class:`~tamr_unify_client.models.category.collection.CategoryCollection` """ alias = self.api_path + "/categories" return CategoryCollection(self.client, alias) diff --git a/tests/unit/test_category.py b/tests/unit/test_category.py index 4cc8ee90..ac2e724b 100644 --- a/tests/unit/test_category.py +++ b/tests/unit/test_category.py @@ -44,7 +44,7 @@ def test_path(self): t1 = t2.parent() self.assertEqual(self._categories_json[0]["relativeId"], t1.relative_id) - self.assertEqual(t1.parent(), None) + self.assertIsNone(t1.parent()) self.assertEqual(t1.path, [t1.name]) self.assertEqual(t2.path, [t1.name, t2.name]) diff --git a/tests/unit/test_taxonomy.py b/tests/unit/test_taxonomy.py index 7c0b0b9f..4124390a 100644 --- a/tests/unit/test_taxonomy.py +++ b/tests/unit/test_taxonomy.py @@ -6,8 +6,8 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth -from tamr_unify_client.models.category.resource import Category from tamr_unify_client.models.category.collection import CategoryCollection +from tamr_unify_client.models.category.resource import Category from tamr_unify_client.models.taxonomy.resource import Taxonomy @@ -73,7 +73,9 @@ def create_callback(request, snoop): "http://localhost:9100/api/versioned/v1/projects/1/taxonomy/categories:bulk" ) snoop_dict = {} - responses.add_callback(responses.POST, post_url, partial(create_callback, snoop=snoop_dict)) + responses.add_callback( + responses.POST, post_url, partial(create_callback, snoop=snoop_dict) + ) alias = "projects/1/taxonomy/categories" coll = CategoryCollection(self.unify, alias) From a554abc8d7c5851301ab376a07281ebde3f33193 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 18 Jul 2019 12:04:36 -0400 Subject: [PATCH 063/632] refresh estimated pair counts --- .../models/project/estimated_pair_counts.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tamr_unify_client/models/project/estimated_pair_counts.py b/tamr_unify_client/models/project/estimated_pair_counts.py index 362c36a1..d5d82326 100644 --- a/tamr_unify_client/models/project/estimated_pair_counts.py +++ b/tamr_unify_client/models/project/estimated_pair_counts.py @@ -1,4 +1,5 @@ from tamr_unify_client.models.base_resource import BaseResource +from tamr_unify_client.models.operation import Operation class EstimatedPairCounts(BaseResource): @@ -50,6 +51,20 @@ def clause_estimates(self) -> dict: """ return self._data.get("clauseEstimates") + def refresh(self, **options): + """Updates the estimated pair counts if needed. + + The pair count estimates are updated on the server; you will need to call + :func:`~tamr_unify_client.models.project.mastering.MasteringProject.estimate_pairs` + to retrieve the updated estimate. + + :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.models.operation.Operation` . + See :func:`~tamr_unify_client.models.operation.Operation.apply_options` . + """ + op_json = self.client.post(self.api_path + ":refresh").successful().json() + op = Operation.from_json(self.client, op_json) + return op.apply_options(**options) + def __repr__(self) -> str: return ( f"{self.__class__.__module__}." From 2dcd13c23a6708deeab7c1bf7e5d8cf8591fef9e Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 18 Jul 2019 13:18:08 -0400 Subject: [PATCH 064/632] testing pair counts --- tests/unit/test_pair_counts.py | 114 +++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 tests/unit/test_pair_counts.py diff --git a/tests/unit/test_pair_counts.py b/tests/unit/test_pair_counts.py new file mode 100644 index 00000000..8f72bed7 --- /dev/null +++ b/tests/unit/test_pair_counts.py @@ -0,0 +1,114 @@ +from unittest import TestCase + +import responses + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth +from tamr_unify_client.models.operation import Operation +from tamr_unify_client.models.project.estimated_pair_counts import EstimatedPairCounts +from tamr_unify_client.models.project.mastering import MasteringProject + + +class TestPairCounts(TestCase): + def setUp(self): + auth = UsernamePasswordAuth("username", "password") + self.unify = Client(auth) + + @responses.activate + def test_get(self): + p = MasteringProject(self.unify, self._project_json) + responses.add( + responses.GET, + f"{self._url_base}/{self._api_path}", + json=self._estimate_json, + ) + generated = p.estimate_pairs() + + created = EstimatedPairCounts.from_json( + self.unify, self._estimate_json, self._api_path + ) + self.assertEqual(repr(generated), repr(created)) + + def test_properties(self): + estimate = EstimatedPairCounts.from_json( + self.unify, self._estimate_json, self._api_path + ) + self.assertFalse(estimate.is_up_to_date) + self.assertEqual(estimate.total_estimate, self._estimate_json["totalEstimate"]) + self.assertEqual( + estimate.clause_estimates, self._estimate_json["clauseEstimates"] + ) + + @responses.activate + def test_refresh(self): + responses.add( + responses.POST, + f"{self._url_base}/{self._api_path}:refresh", + json=self._refresh_json, + ) + updated = self._refresh_json.copy() + updated["status"]["state"] = "SUCCEEDED" + responses.add(responses.GET, f"{self._url_base}/operations/24", json=updated) + + estimate = EstimatedPairCounts.from_json( + self.unify, self._estimate_json, self._api_path + ) + generated = estimate.refresh() + + created = Operation.from_json(self.unify, updated) + self.assertEqual(repr(generated), repr(created)) + + _url_base = "http://localhost:9100/api/versioned/v1" + _api_path = "projects/1/estimatedPairCounts" + + _project_json = { + "id": "unify://unified-data/v1/projects/1", + "name": "mastering", + "description": "Mastering Project", + "type": "DEDUP", + "unifiedDatasetName": "mastering_unified_dataset", + "created": { + "username": "admin", + "time": "2019-07-08T20:14:46.904Z", + "version": "20", + }, + "lastModified": { + "username": "admin", + "time": "2019-07-08T20:18:13.629Z", + "version": "89", + }, + "relativeId": "projects/1", + "externalId": "1c2a10e5-e602-47ac-ade8-f9c23e49dfd2", + } + + _estimate_json = { + "isUpToDate": False, + "totalEstimate": {"candidatePairCount": "150", "generatedPairCount": "75"}, + "clauseEstimates": { + "clause1": {"candidatePairCount": "50", "generatedPairCount": "25"}, + "clause2": {"candidatePairCount": "100", "generatedPairCount": "50"}, + }, + } + + _refresh_json = { + "id": "24", + "type": "SPARK", + "description": "Generate Pair Estimates", + "status": { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark", + }, + "created": { + "username": "admin", + "time": "2019-07-18T15:40:26.974Z", + "version": "1052", + }, + "lastModified": { + "username": "admin", + "time": "2019-07-18T15:40:26.974Z", + "version": "1052", + }, + "relativeId": "operations/24", + } From f59796cdf84d708cc27b98312ec44d8353e0d6dd Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 18 Jul 2019 16:21:40 -0400 Subject: [PATCH 065/632] update docs and changelog --- CHANGELOG.md | 1 + docs/developer-interface.rst | 6 ++++++ tamr_unify_client/models/project/mastering.py | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afe64fc1..646060d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## 0.8.0-dev **NEW FEATURES** - [#174](https://github.com/Datatamer/unify-client-python/issues/174) Get and create taxonomy categories + - [#182](https://github.com/Datatamer/unify-client-python/issues/182) Add the ability to refresh estimated pair counts. ## 0.7.0 **BREAKING CHANGES** diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index 8ccc038d..54308c0a 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -64,6 +64,12 @@ Attributes .. autoclass:: tamr_unify_client.models.attribute.collection.AttributeCollection +Estimated Pair Counts +--------------------- + +.. autoclass:: tamr_unify_client.models.project.estimated_pair_counts.EstimatedPairCounts + :members: + Machine Learning Models ----------------------- diff --git a/tamr_unify_client/models/project/mastering.py b/tamr_unify_client/models/project/mastering.py index 574d8f67..d00f5d9f 100644 --- a/tamr_unify_client/models/project/mastering.py +++ b/tamr_unify_client/models/project/mastering.py @@ -89,7 +89,7 @@ def estimate_pairs(self): """Returns pair estimate information for a mastering project :return: Pairs Estimate information. - :rtype: :class:`~tamr_unify_client.models.project.estimated_pair_counts` + :rtype: :class:`~tamr_unify_client.models.project.estimated_pair_counts.EstimatedPairCounts` """ alias = self.api_path + "/estimatedPairCounts" estimate_json = self.client.get(alias).successful().json() From e0022bae0c39b8df2033e6d57aa93ac544074797 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 19 Jul 2019 12:58:07 -0400 Subject: [PATCH 066/632] published cluster configuration --- CHANGELOG.md | 1 + tamr_unify_client/models/project/mastering.py | 14 ++++ tests/unit/test_published_clusters.py | 78 ++++++++++++------- 3 files changed, 64 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 646060d6..44d5f37a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ **NEW FEATURES** - [#174](https://github.com/Datatamer/unify-client-python/issues/174) Get and create taxonomy categories - [#182](https://github.com/Datatamer/unify-client-python/issues/182) Add the ability to refresh estimated pair counts. + - [#184](https://github.com/Datatamer/unify-client-python/issues/184) Support for getting published cluster configurations ## 0.7.0 **BREAKING CHANGES** diff --git a/tamr_unify_client/models/project/mastering.py b/tamr_unify_client/models/project/mastering.py index d00f5d9f..d4598dea 100644 --- a/tamr_unify_client/models/project/mastering.py +++ b/tamr_unify_client/models/project/mastering.py @@ -85,6 +85,20 @@ def published_clusters(self): alias = self.api_path + "/publishedClusters" return Dataset.from_json(self.client, resource_json, alias) + def published_clusters_configuration(self): + """Retrives published clusters configuration for this project. + + :returns: The returned JSON body, as specified in the + `Public Docs for Cluster Configurations + `_. + :rtype: dict + """ + return ( + self.client.get(self.api_path + "/publishedClustersConfiguration") + .successful() + .json() + ) + def estimate_pairs(self): """Returns pair estimate information for a mastering project diff --git a/tests/unit/test_published_clusters.py b/tests/unit/test_published_clusters.py index 8a83a575..541cc4f1 100644 --- a/tests/unit/test_published_clusters.py +++ b/tests/unit/test_published_clusters.py @@ -1,13 +1,54 @@ +from unittest import TestCase + import responses from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth +from tamr_unify_client.models.project.resource import Project + + +class PublishedClusterTest(TestCase): + def setUp(self): + auth = UsernamePasswordAuth("username", "password") + self.unify = Client(auth) + + @responses.activate + def test_published_clusters(self): + datasets_json = [self._published_clusters_json] + project_id = "1" + project_url = f"http://localhost:9100/api/versioned/v1/projects/{project_id}" + unified_dataset_url = f"http://localhost:9100/api/versioned/v1/projects/{project_id}/unifiedDataset" + datasets_url = f"http://localhost:9100/api/versioned/v1/datasets" + refresh_url = f"http://localhost:9100/api/versioned/v1/projects/{project_id}/publishedClusters:refresh" + operations_url = f"http://localhost:9100/api/versioned/v1/operations/93" -@responses.activate -def test_published_clusters(): + responses.add(responses.GET, project_url, json=self._project_config_json) + responses.add( + responses.GET, unified_dataset_url, json=self._unified_dataset_json + ) + responses.add(responses.GET, datasets_url, json=datasets_json) + responses.add(responses.POST, refresh_url, json=self._refresh_json) + responses.add(responses.GET, operations_url, json=self._operations_json) + project = self.unify.projects.by_resource_id(project_id) + actual_published_clusters_dataset = project.as_mastering().published_clusters() + actual_published_clusters_dataset.refresh() + self.assertEqual( + actual_published_clusters_dataset.name, + self._published_clusters_json["name"], + ) - project_config_json = { + @responses.activate + def test_published_clusters_configuration(self): + config_url = f"http://localhost:9100/api/versioned/v1/projects/1/publishedClustersConfiguration" + responses.add(responses.GET, config_url, json=self._config_json) + + p = Project(self.unify, self._project_config_json, "projects/1").as_mastering() + config = p.published_clusters_configuration() + + self.assertEqual(config, self._config_json) + + _project_config_json = { "id": "unify://unified-data/v1/projects/1", "name": "Project_1", "description": "Mastering Project", @@ -17,7 +58,7 @@ def test_published_clusters(): "externalId": "32b99cab-e01b-41e7-a29d-509165242c6f", } - unified_dataset_json = { + _unified_dataset_json = { "id": "unify://unified-data/v1/datasets/8", "name": "Project_1_unified_dataset", "version": "10", @@ -25,7 +66,7 @@ def test_published_clusters(): "externalId": "Project_1_unified_dataset", } - published_clusters_json = { + _published_clusters_json = { "id": "unify://unified-data/v1/datasets/32", "name": "Project_1_unified_dataset_dedup_published_clusters", "description": "All the mappings of records to clusters.", @@ -34,9 +75,7 @@ def test_published_clusters(): "externalId": "Project_1_unified_dataset_dedup_published_clusters", } - datasets_json = [published_clusters_json] - - refresh_json = { + _refresh_json = { "id": "93", "type": "SPARK", "description": "Publish clusters", @@ -59,7 +98,7 @@ def test_published_clusters(): "relativeId": "operations/93", } - operations_json = { + _operations_json = { "id": "93", "type": "SPARK", "description": "Publish clusters", @@ -81,23 +120,4 @@ def test_published_clusters(): "relativeId": "operations/93", } - unify = Client(UsernamePasswordAuth("username", "password")) - project_id = "1" - - project_url = f"http://localhost:9100/api/versioned/v1/projects/{project_id}" - unified_dataset_url = ( - f"http://localhost:9100/api/versioned/v1/projects/{project_id}/unifiedDataset" - ) - datasets_url = f"http://localhost:9100/api/versioned/v1/datasets" - refresh_url = f"http://localhost:9100/api/versioned/v1/projects/{project_id}/publishedClusters:refresh" - operations_url = f"http://localhost:9100/api/versioned/v1/operations/93" - - responses.add(responses.GET, project_url, json=project_config_json) - responses.add(responses.GET, unified_dataset_url, json=unified_dataset_json) - responses.add(responses.GET, datasets_url, json=datasets_json) - responses.add(responses.POST, refresh_url, json=refresh_json) - responses.add(responses.GET, operations_url, json=operations_json) - project = unify.projects.by_resource_id(project_id) - actual_published_clusters_dataset = project.as_mastering().published_clusters() - actual_published_clusters_dataset.refresh() - assert actual_published_clusters_dataset.name == published_clusters_json["name"] + _config_json = {"versionsTimeToLive": "P4D"} From 8893a1669eecb6ee975bc37768762a7e8acedcdb Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Sat, 20 Jul 2019 09:36:33 -0400 Subject: [PATCH 067/632] making cluster configuration its own class --- docs/developer-interface.rst | 5 +++ .../models/project/cluster_configuration.py | 44 +++++++++++++++++++ tamr_unify_client/models/project/mastering.py | 19 ++++---- tests/unit/test_published_clusters.py | 16 +++++-- 4 files changed, 72 insertions(+), 12 deletions(-) create mode 100644 tamr_unify_client/models/project/cluster_configuration.py diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index 54308c0a..a2f99354 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -104,6 +104,11 @@ Project .. autoclass:: tamr_unify_client.models.project.estimated_pair_counts.EstimatedPairCounts :members: +---- + +.. autoclass:: tamr_unify_client.models.project.cluster_configuration.PublishedClustersConfiguration + :members: + Projects -------- diff --git a/tamr_unify_client/models/project/cluster_configuration.py b/tamr_unify_client/models/project/cluster_configuration.py new file mode 100644 index 00000000..af0278c3 --- /dev/null +++ b/tamr_unify_client/models/project/cluster_configuration.py @@ -0,0 +1,44 @@ +from tamr_unify_client.models.base_resource import BaseResource + + +class PublishedClustersConfiguration(BaseResource): + """ + The configuration of published clusters in a project. + + See https://docs.tamr.com/reference#the-published-clusters-configuration-object + """ + + @classmethod + def from_json(cls, client, data, api_path): + return super().from_data(client, data, api_path) + + @property + def relative_id(self): + """:type: str""" + # api_path is alias when it exists, and relative_id when it does not. + # this distinction is useful for things like refreshing a unified dataset, + # where using the relative_id would hit + # /datasets/{id}:refresh + # rather than + # /projects/{id}/unifiedDataset:refresh. + # Since cluster configurations don't currently have that kind of aliasing, + # using api_path is always correct. + # If configurations ever get aliased, this will need to be updated. + # This is confusing; there's an RFC for suggestions to improve this + # #64 https://github.com/Datatamer/unify-client-python/issues/64 + # "Conflation between 'api_path', 'relative_id' / 'relativeId', and + # BaseResource ctor 'alias'" + return self.api_path + + @property + def versions_time_to_live(self): + """:type: str""" + return self._data.get("versionsTimeToLive") + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"relative_id={self.relative_id!r}, " + f"versions_time_to_live={self.versions_time_to_live!r})" + ) diff --git a/tamr_unify_client/models/project/mastering.py b/tamr_unify_client/models/project/mastering.py index d4598dea..d69db4f4 100644 --- a/tamr_unify_client/models/project/mastering.py +++ b/tamr_unify_client/models/project/mastering.py @@ -1,6 +1,9 @@ from tamr_unify_client.models.binning_model import BinningModel from tamr_unify_client.models.dataset.resource import Dataset from tamr_unify_client.models.machine_learning_model import MachineLearningModel +from tamr_unify_client.models.project.cluster_configuration import ( + PublishedClustersConfiguration, +) from tamr_unify_client.models.project.estimated_pair_counts import EstimatedPairCounts from tamr_unify_client.models.project.resource import Project @@ -86,17 +89,15 @@ def published_clusters(self): return Dataset.from_json(self.client, resource_json, alias) def published_clusters_configuration(self): - """Retrives published clusters configuration for this project. + """Retrieves published clusters configuration for this project. - :returns: The returned JSON body, as specified in the - `Public Docs for Cluster Configurations - `_. - :rtype: dict + :returns: The published clusters configuration + :rtype: :class:`~tamr_unify_client.models.project.cluster_configuration.PublishedClustersConfiguration` """ - return ( - self.client.get(self.api_path + "/publishedClustersConfiguration") - .successful() - .json() + alias = self.api_path + "/publishedClustersConfiguration" + resource_json = self.client.get(alias).successful().json() + return PublishedClustersConfiguration.from_json( + self.client, resource_json, alias ) def estimate_pairs(self): diff --git a/tests/unit/test_published_clusters.py b/tests/unit/test_published_clusters.py index 541cc4f1..13a92a67 100644 --- a/tests/unit/test_published_clusters.py +++ b/tests/unit/test_published_clusters.py @@ -4,6 +4,9 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth +from tamr_unify_client.models.project.cluster_configuration import ( + PublishedClustersConfiguration, +) from tamr_unify_client.models.project.resource import Project @@ -40,13 +43,20 @@ def test_published_clusters(self): @responses.activate def test_published_clusters_configuration(self): - config_url = f"http://localhost:9100/api/versioned/v1/projects/1/publishedClustersConfiguration" + path = "projects/1/publishedClustersConfiguration" + config_url = f"http://localhost:9100/api/versioned/v1/{path}" responses.add(responses.GET, config_url, json=self._config_json) - p = Project(self.unify, self._project_config_json, "projects/1").as_mastering() + p = Project(self.unify, self._project_config_json).as_mastering() config = p.published_clusters_configuration() + created = PublishedClustersConfiguration.from_json( + self.unify, self._config_json, path + ) - self.assertEqual(config, self._config_json) + self.assertEqual(repr(config), repr(created)) + self.assertEqual( + config.versions_time_to_live, self._config_json["versionsTimeToLive"] + ) _project_config_json = { "id": "unify://unified-data/v1/projects/1", From fe5cc8ff2bfcac584254c2171eeb9876ec8c125e Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Mon, 22 Jul 2019 16:42:20 -0400 Subject: [PATCH 068/632] refresh published cluster ids --- CHANGELOG.md | 1 + tamr_unify_client/models/project/mastering.py | 15 +++++++++++++++ tests/unit/test_published_clusters.py | 11 +++++++++++ 3 files changed, 27 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44d5f37a..6255ada1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - [#174](https://github.com/Datatamer/unify-client-python/issues/174) Get and create taxonomy categories - [#182](https://github.com/Datatamer/unify-client-python/issues/182) Add the ability to refresh estimated pair counts. - [#184](https://github.com/Datatamer/unify-client-python/issues/184) Support for getting published cluster configurations + - [#201](https://github.com/Datatamer/unify-client-python/issues/201) Support for refreshing published cluster IDs ## 0.7.0 **BREAKING CHANGES** diff --git a/tamr_unify_client/models/project/mastering.py b/tamr_unify_client/models/project/mastering.py index d69db4f4..8ffd65c3 100644 --- a/tamr_unify_client/models/project/mastering.py +++ b/tamr_unify_client/models/project/mastering.py @@ -1,6 +1,7 @@ from tamr_unify_client.models.binning_model import BinningModel from tamr_unify_client.models.dataset.resource import Dataset from tamr_unify_client.models.machine_learning_model import MachineLearningModel +from tamr_unify_client.models.operation import Operation from tamr_unify_client.models.project.cluster_configuration import ( PublishedClustersConfiguration, ) @@ -100,6 +101,20 @@ def published_clusters_configuration(self): self.client, resource_json, alias ) + def refresh_published_cluster_ids(self, **options): + """ + Updates published clusters for this project. + + :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.models.operation.Operation` . + See :func:`~tamr_unify_client.models.operation.Operation.apply_options` . + :returns: The operation to update published clusters. + :rtype: :class:`~tamr_unify_client.models.operation.Operation` + """ + path = self.api_path + "/allPublishedClusterIds:refresh" + op_json = self.client.post(path).successful().json() + op = Operation.from_json(self.client, op_json) + return op.apply_options(**options) + def estimate_pairs(self): """Returns pair estimate information for a mastering project diff --git a/tests/unit/test_published_clusters.py b/tests/unit/test_published_clusters.py index 13a92a67..0a76ad17 100644 --- a/tests/unit/test_published_clusters.py +++ b/tests/unit/test_published_clusters.py @@ -58,6 +58,17 @@ def test_published_clusters_configuration(self): config.versions_time_to_live, self._config_json["versionsTimeToLive"] ) + @responses.activate + def test_refresh_ids(self): + path = "projects/1/allPublishedClusterIds:refresh" + refresh_url = f"http://localhost:9100/api/versioned/v1/{path}" + responses.add(responses.POST, refresh_url, json=self._operations_json) + + p = Project(self.unify, self._project_config_json).as_mastering() + op = p.refresh_published_cluster_ids() + self.assertEqual(op.resource_id, self._operations_json["id"]) + self.assertTrue(op.succeeded()) + _project_config_json = { "id": "unify://unified-data/v1/projects/1", "name": "Project_1", From 33bdbbd41699d4b109c26d3fb0d470c84e44da18 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 19 Jul 2019 10:35:33 -0400 Subject: [PATCH 069/632] remove attribute collection data --- tamr_unify_client/models/attribute/collection.py | 14 +++----------- tamr_unify_client/models/attribute/resource.py | 3 +-- tamr_unify_client/models/dataset/resource.py | 3 +-- tamr_unify_client/models/project/resource.py | 3 +-- 4 files changed, 6 insertions(+), 17 deletions(-) diff --git a/tamr_unify_client/models/attribute/collection.py b/tamr_unify_client/models/attribute/collection.py index 79eba8cb..7e1ce8bf 100644 --- a/tamr_unify_client/models/attribute/collection.py +++ b/tamr_unify_client/models/attribute/collection.py @@ -7,22 +7,13 @@ class AttributeCollection(BaseCollection): :param client: Client for API call delegation. :type client: :class:`~tamr_unify_client.Client` - :param data: JSON data representing this resource - :type data: dict :param api_path: API path used to access this collection. E.g. ``"datasets/1/attributes"``. :type api_path: str """ - def __init__(self, client, data, api_path): + def __init__(self, client, api_path): super().__init__(client, api_path) - self._data = data - - @classmethod - def from_json(cls, client, data, api_path): - # BaseCollection doesn't really implement from_json / from_data - # but we pretend it does. - return AttributeCollection(client, data, api_path) def by_resource_id(self, resource_id): """Retrieve an attribute by resource ID. @@ -73,7 +64,8 @@ def stream(self): >>> for attribute in collection: # implicit >>> do_stuff(attribute) """ - for resource_json in self._data: + data = self.client.get(self.api_path).successful().json() + for resource_json in data: alias = self.api_path + "/" + resource_json["name"] yield Attribute.from_json(self.client, resource_json, alias) diff --git a/tamr_unify_client/models/attribute/resource.py b/tamr_unify_client/models/attribute/resource.py index 753fab77..0fc25ee6 100644 --- a/tamr_unify_client/models/attribute/resource.py +++ b/tamr_unify_client/models/attribute/resource.py @@ -44,9 +44,8 @@ def description(self): @property def type(self): """:type: :class:`~tamr_unify_client.models.attribute.type.AttributeType`""" - alias = self.api_path + "/type" type_json = self._data.get("type") - return AttributeType.from_data(self.client, type_json, alias) + return AttributeType(self.client, type_json) @property def is_nullable(self): diff --git a/tamr_unify_client/models/dataset/resource.py b/tamr_unify_client/models/dataset/resource.py index 82331f74..a0c14a54 100644 --- a/tamr_unify_client/models/dataset/resource.py +++ b/tamr_unify_client/models/dataset/resource.py @@ -52,8 +52,7 @@ def attributes(self): :rtype: :class:`~tamr_unify_client.models.attribute.collection.AttributeCollection` """ alias = self.api_path + "/attributes" - resource_json = self.client.get(alias).successful().json() - return AttributeCollection.from_json(self.client, resource_json, alias) + return AttributeCollection(self.client, alias) def update_records(self, records): """Send a batch of record creations/updates/deletions to this dataset. diff --git a/tamr_unify_client/models/project/resource.py b/tamr_unify_client/models/project/resource.py index ca8a95b8..e23c7389 100644 --- a/tamr_unify_client/models/project/resource.py +++ b/tamr_unify_client/models/project/resource.py @@ -47,8 +47,7 @@ def attributes(self): from tamr_unify_client.models.attribute.collection import AttributeCollection alias = self.api_path + "/attributes" - resource_json = self.client.get(alias).successful().json() - return AttributeCollection.from_json(self.client, resource_json, alias) + return AttributeCollection(self.client, alias) def unified_dataset(self): """Unified dataset for this project. From 48f142ba1a27a2ad5acf8c4b53265555b96c41e6 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Mon, 22 Jul 2019 11:32:03 -0400 Subject: [PATCH 070/632] creating subattribute --- .../models/attribute/subattribute.py | 45 +++++++++++++++++++ tamr_unify_client/models/attribute/type.py | 36 +++++++-------- 2 files changed, 62 insertions(+), 19 deletions(-) create mode 100644 tamr_unify_client/models/attribute/subattribute.py diff --git a/tamr_unify_client/models/attribute/subattribute.py b/tamr_unify_client/models/attribute/subattribute.py new file mode 100644 index 00000000..f9ed54b4 --- /dev/null +++ b/tamr_unify_client/models/attribute/subattribute.py @@ -0,0 +1,45 @@ +class SubAttribute: + """ + An attribute which is itself a property of another attribute. + See https://docs.tamr.com/reference#attribute-types + + :param client: Delegate underlying API calls to this client. + :type: :class:`~tamr_unify_client.Client` + :param data: JSON data representing this attribute + :type: :py:class:`dict` + """ + + def __init__(self, client, data): + self.client = client + self._data = data + + @property + def name(self): + """:type: str""" + return self._data.get("name") + + @property + def description(self): + """:type: str""" + return self._data.get("description") + + @property + def type(self): + """:type: :class:`~tamr_unify_client.models.attribute.type.AttributeType`""" + # import locally to avoid circular dependency + from tamr_unify_client.models.attribute.type import AttributeType + + type_json = self._data.get("type") + return AttributeType(self.client, type_json) + + @property + def is_nullable(self): + """:type: bool""" + return self._data.get("isNullable") + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"name={self.name!r})" + ) diff --git a/tamr_unify_client/models/attribute/type.py b/tamr_unify_client/models/attribute/type.py index 87bc7b5f..0c57df4d 100644 --- a/tamr_unify_client/models/attribute/type.py +++ b/tamr_unify_client/models/attribute/type.py @@ -1,14 +1,20 @@ -from tamr_unify_client.models.base_resource import BaseResource +from tamr_unify_client.models.attribute.subattribute import SubAttribute -class AttributeType(BaseResource): - @classmethod - def from_json(cls, client, data, api_path): - return super().from_data(client, data, api_path) +class AttributeType: + """ + The type of an :class:`~tamr_unify_client.models.attribute.resource.Attribute` or :class:`~tamr_unify_client.models.attribute.subattribute.SubAttribute`. + See https://docs.tamr.com/reference#attribute-types - @property - def relative_id(self): - return self.api_path + :param client: Delegate underlying API calls to this client. + :type: :class:`~tamr_unify_client.Client` + :param data: JSON data representing this type + :type: :py:class:`dict` + """ + + def __init__(self, client, data): + self.client = client + self._data = data @property def base_type(self): @@ -19,27 +25,19 @@ def base_type(self): def inner_type(self): """:type: :class:`~tamr_unify_client.models.attribute.type.AttributeType`""" if "innerType" in self._data: - alias = self.api_path + "/type" - return AttributeType.from_data( - self.client, self._data.get("innerType"), alias - ) + return AttributeType(self.client, self._data.get("innerType")) else: return None @property def attributes(self): - """:type: :class:`~tamr_unify_client.models.attribute.collection.AttributeCollection`""" - alias = self.api_path + "/attributes" + """:type: list[:class:`~tamr_unify_client.models.attribute.subattribute.SubAttribute`]""" collection_json = self._data.get("attributes") - # Import locally to avoid circular dependency - from tamr_unify_client.models.attribute.collection import AttributeCollection - - return AttributeCollection.from_json(self.client, collection_json, alias) + return [SubAttribute(self.client, attr) for attr in collection_json] def __repr__(self): return ( f"{self.__class__.__module__}." f"{self.__class__.__qualname__}(" - f"relative_id={self.relative_id!r}, " f"base_type={self.base_type!r})" ) From f78fc798e656c060e8f19a2849f939a3df673453 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Mon, 22 Jul 2019 14:33:06 -0400 Subject: [PATCH 071/632] update attribute testing --- tests/unit/test_attribute.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/unit/test_attribute.py b/tests/unit/test_attribute.py index ad88b44a..59883230 100644 --- a/tests/unit/test_attribute.py +++ b/tests/unit/test_attribute.py @@ -50,9 +50,8 @@ def test_complex_type(self): self.assertIsNone(geom.type.inner_type) self.assertEqual(3, len(list(geom.type.attributes))) - point = list(geom.type.attributes)[0] + point = geom.type.attributes[0] self.assertEqual("point", point.name) - self.assertEqual(alias + "/type/attributes/point", point.relative_id) self.assertTrue(point.is_nullable) self.assertEqual("ARRAY", point.type.base_type) self.assertEqual("DOUBLE", point.type.inner_type.base_type) From 1ba398d73234e218d3c6f39ab5b2e0b881d797c5 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Mon, 22 Jul 2019 12:53:00 -0400 Subject: [PATCH 072/632] docs and changelog --- CHANGELOG.md | 5 +++++ docs/developer-interface.rst | 2 ++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6255ada1..977a2d26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ ## 0.8.0-dev + **BREAKING CHANGES** + - [#175](https://github.com/Datatamer/unify-client-python/issues/175) `AttributeCollection` no longer has a `from_json` method or a `data` parameter in its constructor + - `AttributeType` no longer inherits from `BaseResource` (no API path), removing its `from_json` method and `relative_id` property + - The type of `AttributeType`'s `attributes` property is now a `list` of `SubAttribute`s, which are identical to `Attribute`s except they lack an API path + **NEW FEATURES** - [#174](https://github.com/Datatamer/unify-client-python/issues/174) Get and create taxonomy categories - [#182](https://github.com/Datatamer/unify-client-python/issues/182) Add the ability to refresh estimated pair counts. diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index a2f99354..1f71c41b 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -54,6 +54,8 @@ Attribute .. autoclass:: tamr_unify_client.models.attribute.resource.Attribute +.. autoclass:: tamr_unify_client.models.attribute.subattribute.SubAttribute + Attribute Type -------------- From 71b50fc1e1c7c75f7ee824e88b5f2f375f7a7096 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 23 Jul 2019 08:59:28 -0400 Subject: [PATCH 073/632] removing client from non base resources --- tamr_unify_client/models/attribute/resource.py | 2 +- tamr_unify_client/models/attribute/subattribute.py | 7 ++----- tamr_unify_client/models/attribute/type.py | 9 +++------ 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/tamr_unify_client/models/attribute/resource.py b/tamr_unify_client/models/attribute/resource.py index 0fc25ee6..d1888324 100644 --- a/tamr_unify_client/models/attribute/resource.py +++ b/tamr_unify_client/models/attribute/resource.py @@ -45,7 +45,7 @@ def description(self): def type(self): """:type: :class:`~tamr_unify_client.models.attribute.type.AttributeType`""" type_json = self._data.get("type") - return AttributeType(self.client, type_json) + return AttributeType(type_json) @property def is_nullable(self): diff --git a/tamr_unify_client/models/attribute/subattribute.py b/tamr_unify_client/models/attribute/subattribute.py index f9ed54b4..cc807647 100644 --- a/tamr_unify_client/models/attribute/subattribute.py +++ b/tamr_unify_client/models/attribute/subattribute.py @@ -3,14 +3,11 @@ class SubAttribute: An attribute which is itself a property of another attribute. See https://docs.tamr.com/reference#attribute-types - :param client: Delegate underlying API calls to this client. - :type: :class:`~tamr_unify_client.Client` :param data: JSON data representing this attribute :type: :py:class:`dict` """ - def __init__(self, client, data): - self.client = client + def __init__(self, data): self._data = data @property @@ -30,7 +27,7 @@ def type(self): from tamr_unify_client.models.attribute.type import AttributeType type_json = self._data.get("type") - return AttributeType(self.client, type_json) + return AttributeType(type_json) @property def is_nullable(self): diff --git a/tamr_unify_client/models/attribute/type.py b/tamr_unify_client/models/attribute/type.py index 0c57df4d..abcf7eef 100644 --- a/tamr_unify_client/models/attribute/type.py +++ b/tamr_unify_client/models/attribute/type.py @@ -6,14 +6,11 @@ class AttributeType: The type of an :class:`~tamr_unify_client.models.attribute.resource.Attribute` or :class:`~tamr_unify_client.models.attribute.subattribute.SubAttribute`. See https://docs.tamr.com/reference#attribute-types - :param client: Delegate underlying API calls to this client. - :type: :class:`~tamr_unify_client.Client` :param data: JSON data representing this type :type: :py:class:`dict` """ - def __init__(self, client, data): - self.client = client + def __init__(self, data): self._data = data @property @@ -25,7 +22,7 @@ def base_type(self): def inner_type(self): """:type: :class:`~tamr_unify_client.models.attribute.type.AttributeType`""" if "innerType" in self._data: - return AttributeType(self.client, self._data.get("innerType")) + return AttributeType(self._data.get("innerType")) else: return None @@ -33,7 +30,7 @@ def inner_type(self): def attributes(self): """:type: list[:class:`~tamr_unify_client.models.attribute.subattribute.SubAttribute`]""" collection_json = self._data.get("attributes") - return [SubAttribute(self.client, attr) for attr in collection_json] + return [SubAttribute(attr) for attr in collection_json] def __repr__(self): return ( From aa426846bcf148aba234b772404ceb82dfe19bc9 Mon Sep 17 00:00:00 2001 From: Charlotte Moremen Date: Thu, 18 Jul 2019 12:19:00 -0400 Subject: [PATCH 074/632] finalattribute config updated after Julia's comments testing Monday morning monday morning 2 testing 2 revised after J's comments update revised Tues afternoon after J's comments fixed duplicate method in test_project revised after J's comments Wed morning --- CHANGELOG.md | 1 + docs/developer-interface.rst | 10 + .../attribute_configuration/__init__.py | 0 .../attribute_configuration/collection.py | 75 ++++ .../attribute_configuration/resource.py | 74 ++++ tamr_unify_client/models/project/resource.py | 12 + tests/unit/test_attribute_configuration.py | 62 ++++ ...test_attribute_configuration_collection.py | 331 ++++++++++++++++++ tests/unit/test_project.py | 9 + 9 files changed, 574 insertions(+) create mode 100644 tamr_unify_client/models/attribute_configuration/__init__.py create mode 100644 tamr_unify_client/models/attribute_configuration/collection.py create mode 100644 tamr_unify_client/models/attribute_configuration/resource.py create mode 100644 tests/unit/test_attribute_configuration.py create mode 100644 tests/unit/test_attribute_configuration_collection.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 977a2d26..b4b53941 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - [#182](https://github.com/Datatamer/unify-client-python/issues/182) Add the ability to refresh estimated pair counts. - [#184](https://github.com/Datatamer/unify-client-python/issues/184) Support for getting published cluster configurations - [#201](https://github.com/Datatamer/unify-client-python/issues/201) Support for refreshing published cluster IDs + - [#112](https://github.com/Datatamer/unify-client-python/issues/112) Support for attribute configurations ## 0.7.0 **BREAKING CHANGES** diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index 1f71c41b..bdc4a73c 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -66,6 +66,16 @@ Attributes .. autoclass:: tamr_unify_client.models.attribute.collection.AttributeCollection +Attribute Configuration +---------- + +.. autoclass:: tamr_unify_client.models.attribute_configuration.resource.AttributeConfiguration + +Attribute Configurations +---------- + +.. autoclass:: tamr_unify_client.models.attribute_configuration.collection.AttributeConfigurationCollection + Estimated Pair Counts --------------------- diff --git a/tamr_unify_client/models/attribute_configuration/__init__.py b/tamr_unify_client/models/attribute_configuration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tamr_unify_client/models/attribute_configuration/collection.py b/tamr_unify_client/models/attribute_configuration/collection.py new file mode 100644 index 00000000..3b3f95e8 --- /dev/null +++ b/tamr_unify_client/models/attribute_configuration/collection.py @@ -0,0 +1,75 @@ +from tamr_unify_client.models.attribute_configuration.resource import ( + AttributeConfiguration, +) +from tamr_unify_client.models.base_collection import BaseCollection + + +class AttributeConfigurationCollection(BaseCollection): + """Collection of :class'~tamr_unify_client.models.attribute_configuration.resource.AttributeConfiguration~' + :param client: Client for API call delegation. + :type client: :class:`~tamr_unify_client.Client` + :param api_path: API path used to access this collection. + E.g. ``"projects/1/attributeConfigurations"`` + :type api_path: str + """ + + def by_resource_id(self, resource_id): + """Retrieve an attribute configuration by resource ID. + :param resource_id: The resource ID. + :type resource_id: str + :returns: The specified attribute configuration. + :rtype: :class:`~tamr_unify_client.models.attribute_configuration.resource.AttributeConfiguration` + """ + return super().by_resource_id(self.api_path, resource_id) + + def by_relative_id(self, relative_id): + """Retrieve an attribute configuration by relative ID. + :param relative_id: The relative ID. + :type relative_id: str + :returns: The specified attribute configuration. + :rtype: :class:`~tamr_unify_client.models.attribute_configuration.resource.AttributeConfiguration` + """ + return super().by_relative_id(AttributeConfiguration, relative_id) + + def by_external_id(self, external_id): + """Retrieve an attribute configuration by external ID. + + Since attributes do not have external IDs, this method is not supported and will + raise a :class:`NotImplementedError` . + + :param external_id: The external ID. + :type external_id: str + :returns: The specified attribute, if found. + :rtype: :class:`~tamr_unify_client.models.attribute_configuration.resource.AttributeConfiguration` + :raises KeyError: If no attribute with the specified external_id is found + :raises LookupError: If multiple attributes with the specified external_id are found + :raises NotImplementedError: AttributeConfiguration does not support external_id + """ + raise NotImplementedError("AttributeConfiguration does not support external_id") + + def stream(self): + """Stream attribute configurations in this collection. Implicitly called when iterating + over this collection. + + :returns: Stream of attribute configurations. + :rtype: Python generator yielding :class:`~tamr_unify_client.models.attribute_configuration.resource.AttributeConfiguration` + + Usage: + >>> for attributeConfiguration in collection.stream(): # explicit + >>> do_stuff(attributeConfiguration) + >>> for attributeConfiguration in collection: # implicit + >>> do_stuff(attributeConfiguration) + """ + + return super().stream(AttributeConfiguration) + + def create(self, creation_spec): + """Create an Attribute configuration in this collection + :param creation_spec: Attribute configuration creation specification should be formatted as specified in the + `Public Docs for adding an AttributeConfiguration `_. + :type creation_spec: dict[str, str] + :returns: The created Attribute configuration + :rtype: :class:`~tamr_unify_client.models.attribute_configuration.resource.AttributeConfiguration` + """ + data = self.client.post(self.api_path, json=creation_spec).successful().json() + return AttributeConfiguration.from_json(self.client, data) diff --git a/tamr_unify_client/models/attribute_configuration/resource.py b/tamr_unify_client/models/attribute_configuration/resource.py new file mode 100644 index 00000000..982d7eeb --- /dev/null +++ b/tamr_unify_client/models/attribute_configuration/resource.py @@ -0,0 +1,74 @@ +from tamr_unify_client.models.base_resource import BaseResource + + +class AttributeConfiguration(BaseResource): + """The configurations of Unify Attributes. + + See https://docs.tamr.com/reference#the-attribute-configuration-object + """ + + @classmethod + def from_json( + cls, client, resource_json, api_path=None + ) -> "AttributeConfiguration": + return super().from_data(client, resource_json, api_path) + + @property + def relative_id(self): + """:type: str""" + return self._data.get("relativeId") + + @property + def id(self): + """:type: str""" + return self._data.get("id") + + @property + def relative_attribute_id(self): + """:type: str""" + return self._data.get("relativeAttributeId") + + @property + def attribute_role(self): + """:type: str""" + return self._data.get("attributeRole") + + @property + def similarity_function(self): + """:type: str""" + return self._data.get("similarityFunction") + + @property + def enabled_for_ml(self): + """:type: bool""" + return self._data.get("enabledForMl") + + @property + def tokenizer(self): + """:type: str""" + return self._data.get("tokenizer") + + @property + def numeric_field_resolution(self): + """:type: list """ + return self._data.get("numericFieldResolution") + + @property + def attribute_name(self): + """:type: str""" + return self._data.get("attributeName") + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"relative_id={self.relative_id!r}, " + f"id={self.id!r}, " + f"relative_attribute_id={self.relative_attribute_id!r}, " + f"attribute_role={self.attribute_role!r}, " + f"similarity_function={self.similarity_function!r}, " + f"enabled_for_ml={self.enabled_for_ml!r}, " + f"tokenizer={self.tokenizer!r}, " + f"numeric_field_resolution={self.numeric_field_resolution!r}, " + f"attribute_name={self.attribute_name!r})" + ) diff --git a/tamr_unify_client/models/project/resource.py b/tamr_unify_client/models/project/resource.py index e23c7389..8cd4b75f 100644 --- a/tamr_unify_client/models/project/resource.py +++ b/tamr_unify_client/models/project/resource.py @@ -1,3 +1,6 @@ +from tamr_unify_client.models.attribute_configuration.collection import ( + AttributeConfigurationCollection, +) from tamr_unify_client.models.base_resource import BaseResource from tamr_unify_client.models.dataset.collection import DatasetCollection from tamr_unify_client.models.dataset.resource import Dataset @@ -119,6 +122,15 @@ def input_datasets(self): alias = self.api_path + "/inputDatasets" return DatasetCollection(self.client, alias) + def attribute_configurations(self): + """ Project's attribute's configurations. + :returns: the configurations of the attributes of a project + :rtype :class: '~tamr_unify_client.models.attribute_configuration.collection.AttributeConfigurationCollection' + """ + alias = self.api_path + "/attributeConfigurations" + info = AttributeConfigurationCollection(self.client, api_path=alias) + return info + def __repr__(self): return ( f"{self.__class__.__module__}." diff --git a/tests/unit/test_attribute_configuration.py b/tests/unit/test_attribute_configuration.py new file mode 100644 index 00000000..e3953ea8 --- /dev/null +++ b/tests/unit/test_attribute_configuration.py @@ -0,0 +1,62 @@ +from unittest import TestCase + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth +from tamr_unify_client.models.attribute_configuration.resource import ( + AttributeConfiguration, +) + + +class TestAttributeConfiguration(TestCase): + def setUp(self): + auth = UsernamePasswordAuth("username", "password") + self.unify = Client(auth) + + def test_resource(self): + alias = "projects/1/attributeConfigurations/26" + test = AttributeConfiguration(self.unify, self.ac_json, alias) + + expected = alias + self.assertEqual(expected, test.relative_id) + + expected = self.ac_json["id"] + self.assertEqual(expected, test.id) + + expected = self.ac_json["relativeAttributeId"] + self.assertEqual(expected, test.relative_attribute_id) + + expected = self.ac_json["attributeRole"] + self.assertEqual(expected, test.attribute_role) + + expected = self.ac_json["similarityFunction"] + self.assertEqual(expected, test.similarity_function) + + expected = self.ac_json["enabledForMl"] + self.assertEqual(expected, test.enabled_for_ml) + + expected = self.ac_json["tokenizer"] + self.assertEqual(expected, test.tokenizer) + + expected = self.ac_json["numericFieldResolution"] + self.assertEqual(expected, test.numeric_field_resolution) + + expected = self.ac_json["attributeName"] + self.assertEqual(expected, test.attribute_name) + + def test_resource_from_json(self): + alias = "projects/1/attributeConfigurations/26" + expected = AttributeConfiguration(self.unify, self.ac_json, alias) + actual = AttributeConfiguration.from_json(self.unify, self.ac_json, alias) + self.assertEqual(repr(expected), repr(actual)) + + ac_json = { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/26", + "relativeId": "projects/1/attributeConfigurations/26", + "relativeAttributeId": "datasets/8/attributes/surname", + "attributeRole": "CLUSTER_NAME_ATTRIBUTE", + "similarityFunction": "COSINE", + "enabledForMl": True, + "tokenizer": "DEFAULT", + "numericFieldResolution": [], + "attributeName": "surname", + } diff --git a/tests/unit/test_attribute_configuration_collection.py b/tests/unit/test_attribute_configuration_collection.py new file mode 100644 index 00000000..24c4e245 --- /dev/null +++ b/tests/unit/test_attribute_configuration_collection.py @@ -0,0 +1,331 @@ +from unittest import TestCase + +import responses + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth +from tamr_unify_client.models.attribute_configuration.collection import ( + AttributeConfigurationCollection, +) + + +class TestAttributeConfigurationCollection(TestCase): + def setUp(self): + auth = UsernamePasswordAuth("username", "password") + self.unify = Client(auth) + + @responses.activate + def test_by_relative_id(self): + ac_url = f"http://localhost:9100/api/versioned/v1/projects/1/attributeConfigurations/1" + alias = "projects/1/attributeConfigurations/" + ac_test = AttributeConfigurationCollection(self.unify, alias) + expected = self.acc_json[0]["relativeId"] + responses.add(responses.GET, ac_url, json=self.acc_json[0]) + self.assertEqual( + expected, + ac_test.by_relative_id("projects/1/attributeConfigurations/1").relative_id, + ) + + @responses.activate + def test_by_resource_id(self): + ac_url = f"http://localhost:9100/api/versioned/v1/projects/1/attributeConfigurations/1" + alias = "projects/1/attributeConfigurations/" + ac_test = AttributeConfigurationCollection(self.unify, alias) + expected = self.acc_json[0]["relativeId"] + responses.add(responses.GET, ac_url, json=self.acc_json[0]) + self.assertEqual(expected, ac_test.by_resource_id("1").relative_id) + + @responses.activate + def test_create(self): + url = ( + f"http://localhost:9100/api/versioned/v1/projects/1/attributeConfigurations" + ) + project_url = f"http://localhost:9100/api/versioned/v1/projects/1" + responses.add(responses.GET, project_url, json=self.project_json) + responses.add(responses.POST, url, json=self.create_json, status=204) + responses.add(responses.GET, url, json=self.create_json) + + attributeconfig = self.unify.projects.by_resource_id( + "1" + ).attribute_configurations() + create = attributeconfig.create(self.create_json) + + self.assertEqual(create.relative_id, self.create_json["relativeId"]) + + @responses.activate + def test_stream(self): + ac_url = f"http://localhost:9100/api/versioned/v1/projects/1/attributeConfigurations/" + alias = "projects/1/attributeConfigurations/" + ac_test = AttributeConfigurationCollection(self.unify, alias) + responses.add(responses.GET, ac_url, json=self.acc_json) + streamer = ac_test.stream() + stream_content = [] + for char in streamer: + stream_content.append(char._data) + self.assertEqual(self.acc_json, stream_content) + + create_json = { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/35", + "relativeId": "projects/1/attributeConfigurations/35", + "relativeAttributeId": "datasets/79/attributes/Tester", + "attributeRole": "", + "similarityFunction": "ABSOLUTE_DIFF", + "enabledForMl": False, + "tokenizer": "", + "numericFieldResolution": [], + "attributeName": "Tester", + } + + project_json = { + "id": "unify://unified-data/v1/projects/1", + "externalId": "project 1 external ID", + "name": "project 1 name", + "description": "project 1 description", + "type": "DEDUP", + "unifiedDatasetName": "project 1 unified dataset", + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "project 1 created version", + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "project 1 modified version", + }, + "relativeId": "projects/1", + } + + acc_json = [ + { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/1", + "relativeId": "projects/1/attributeConfigurations/1", + "relativeAttributeId": "datasets/8/attributes/suburb", + "attributeRole": "", + "similarityFunction": "COSINE", + "enabledForMl": True, + "tokenizer": "DEFAULT", + "numericFieldResolution": [], + "attributeName": "suburb", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/2", + "relativeId": "projects/1/attributeConfigurations/2", + "relativeAttributeId": "datasets/8/attributes/sex", + "attributeRole": "", + "similarityFunction": "COSINE", + "enabledForMl": True, + "tokenizer": "DEFAULT", + "numericFieldResolution": [], + "attributeName": "sex", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/3", + "relativeId": "projects/1/attributeConfigurations/3", + "relativeAttributeId": "datasets/8/attributes/address_2", + "attributeRole": "", + "similarityFunction": "COSINE", + "enabledForMl": True, + "tokenizer": "DEFAULT", + "numericFieldResolution": [], + "attributeName": "address_2", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/4", + "relativeId": "projects/1/attributeConfigurations/4", + "relativeAttributeId": "datasets/8/attributes/age", + "attributeRole": "", + "similarityFunction": "COSINE", + "enabledForMl": True, + "tokenizer": "DEFAULT", + "numericFieldResolution": [], + "attributeName": "age", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/5", + "relativeId": "projects/1/attributeConfigurations/5", + "relativeAttributeId": "datasets/8/attributes/culture", + "attributeRole": "", + "similarityFunction": "COSINE", + "enabledForMl": True, + "tokenizer": "DEFAULT", + "numericFieldResolution": [], + "attributeName": "culture", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/6", + "relativeId": "projects/1/attributeConfigurations/6", + "relativeAttributeId": "datasets/8/attributes/street_number", + "attributeRole": "", + "similarityFunction": "COSINE", + "enabledForMl": True, + "tokenizer": "DEFAULT", + "numericFieldResolution": [], + "attributeName": "street_number", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/7", + "relativeId": "projects/1/attributeConfigurations/7", + "relativeAttributeId": "datasets/8/attributes/postcode", + "attributeRole": "", + "similarityFunction": "COSINE", + "enabledForMl": True, + "tokenizer": "DEFAULT", + "numericFieldResolution": [], + "attributeName": "postcode", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/8", + "relativeId": "projects/1/attributeConfigurations/8", + "relativeAttributeId": "datasets/8/attributes/phone_number", + "attributeRole": "", + "similarityFunction": "COSINE", + "enabledForMl": True, + "tokenizer": "DEFAULT", + "numericFieldResolution": [], + "attributeName": "phone_number", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/9", + "relativeId": "projects/1/attributeConfigurations/9", + "relativeAttributeId": "datasets/8/attributes/soc_sec_id", + "attributeRole": "", + "similarityFunction": "COSINE", + "enabledForMl": True, + "tokenizer": "DEFAULT", + "numericFieldResolution": [], + "attributeName": "soc_sec_id", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/10", + "relativeId": "projects/1/attributeConfigurations/10", + "relativeAttributeId": "datasets/8/attributes/rec2_id", + "attributeRole": "", + "similarityFunction": "COSINE", + "enabledForMl": True, + "tokenizer": "DEFAULT", + "numericFieldResolution": [], + "attributeName": "rec2_id", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/11", + "relativeId": "projects/1/attributeConfigurations/11", + "relativeAttributeId": "datasets/8/attributes/date_of_birth", + "attributeRole": "", + "similarityFunction": "COSINE", + "enabledForMl": True, + "tokenizer": "DEFAULT", + "numericFieldResolution": [], + "attributeName": "date_of_birth", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/12", + "relativeId": "projects/1/attributeConfigurations/12", + "relativeAttributeId": "datasets/8/attributes/title", + "attributeRole": "", + "similarityFunction": "COSINE", + "enabledForMl": True, + "tokenizer": "DEFAULT", + "numericFieldResolution": [], + "attributeName": "title", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/13", + "relativeId": "projects/1/attributeConfigurations/13", + "relativeAttributeId": "datasets/8/attributes/address_1", + "attributeRole": "", + "similarityFunction": "COSINE", + "enabledForMl": True, + "tokenizer": "DEFAULT", + "numericFieldResolution": [], + "attributeName": "address_1", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/14", + "relativeId": "projects/1/attributeConfigurations/14", + "relativeAttributeId": "datasets/8/attributes/rec_id", + "attributeRole": "", + "similarityFunction": "COSINE", + "enabledForMl": True, + "tokenizer": "DEFAULT", + "numericFieldResolution": [], + "attributeName": "rec_id", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/15", + "relativeId": "projects/1/attributeConfigurations/15", + "relativeAttributeId": "datasets/8/attributes/state", + "attributeRole": "", + "similarityFunction": "COSINE", + "enabledForMl": True, + "tokenizer": "DEFAULT", + "numericFieldResolution": [], + "attributeName": "state", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/16", + "relativeId": "projects/1/attributeConfigurations/16", + "relativeAttributeId": "datasets/8/attributes/family_role", + "attributeRole": "", + "similarityFunction": "COSINE", + "enabledForMl": True, + "tokenizer": "DEFAULT", + "numericFieldResolution": [], + "attributeName": "family_role", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/17", + "relativeId": "projects/1/attributeConfigurations/17", + "relativeAttributeId": "datasets/8/attributes/blocking_number", + "attributeRole": "", + "similarityFunction": "COSINE", + "enabledForMl": True, + "tokenizer": "DEFAULT", + "numericFieldResolution": [], + "attributeName": "blocking_number", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/18", + "relativeId": "projects/1/attributeConfigurations/18", + "relativeAttributeId": "datasets/8/attributes/surname", + "attributeRole": "CLUSTER_NAME_ATTRIBUTE", + "similarityFunction": "COSINE", + "enabledForMl": True, + "tokenizer": "DEFAULT", + "numericFieldResolution": [], + "attributeName": "surname", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/19", + "relativeId": "projects/1/attributeConfigurations/19", + "relativeAttributeId": "datasets/8/attributes/given_name", + "attributeRole": "", + "similarityFunction": "COSINE", + "enabledForMl": True, + "tokenizer": "DEFAULT", + "numericFieldResolution": [], + "attributeName": "given_name", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/20", + "relativeId": "projects/1/attributeConfigurations/20", + "relativeAttributeId": "datasets/8/attributes/Address1", + "attributeRole": "", + "similarityFunction": "COSINE", + "enabledForMl": False, + "tokenizer": "DEFAULT", + "numericFieldResolution": [], + "attributeName": "Address1", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/21", + "relativeId": "projects/1/attributeConfigurations/21", + "relativeAttributeId": "datasets/8/attributes/Address2", + "attributeRole": "", + "similarityFunction": "COSINE", + "enabledForMl": False, + "tokenizer": "DEFAULT", + "numericFieldResolution": [], + "attributeName": "Address2", + }, + ] diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index da65bfbd..049cba3b 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -84,6 +84,15 @@ def test_project_get_input_datasets(self): datasets = p.input_datasets() self.assertEqual(datasets.api_path, "projects/1/inputDatasets") + @responses.activate + def test_return_attribute_collection(self): + responses.add(responses.GET, self.projects_url, json=self.project_json) + project = self.unify.projects.by_external_id(self.project_external_id) + attribute_configs = project.as_mastering().attribute_configurations() + self.assertEqual( + attribute_configs.api_path, "projects/1/attributeConfigurations" + ) + dataset_external_id = "1" datasets_url = f"http://localhost:9100/api/versioned/v1/datasets?filter=externalId=={dataset_external_id}" dataset_json = [ From 63e3ade38f1e4c5580be670843688870cfcb2e55 Mon Sep 17 00:00:00 2001 From: Charlotte Moremen Date: Wed, 24 Jul 2019 11:40:52 -0400 Subject: [PATCH 075/632] backticks --- tamr_unify_client/models/attribute_configuration/collection.py | 2 +- tamr_unify_client/models/project/resource.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tamr_unify_client/models/attribute_configuration/collection.py b/tamr_unify_client/models/attribute_configuration/collection.py index 3b3f95e8..6bcf2510 100644 --- a/tamr_unify_client/models/attribute_configuration/collection.py +++ b/tamr_unify_client/models/attribute_configuration/collection.py @@ -5,7 +5,7 @@ class AttributeConfigurationCollection(BaseCollection): - """Collection of :class'~tamr_unify_client.models.attribute_configuration.resource.AttributeConfiguration~' + """Collection of :class`~tamr_unify_client.models.attribute_configuration.resource.AttributeConfiguration~` :param client: Client for API call delegation. :type client: :class:`~tamr_unify_client.Client` :param api_path: API path used to access this collection. diff --git a/tamr_unify_client/models/project/resource.py b/tamr_unify_client/models/project/resource.py index 8cd4b75f..290d3099 100644 --- a/tamr_unify_client/models/project/resource.py +++ b/tamr_unify_client/models/project/resource.py @@ -125,7 +125,7 @@ def input_datasets(self): def attribute_configurations(self): """ Project's attribute's configurations. :returns: the configurations of the attributes of a project - :rtype :class: '~tamr_unify_client.models.attribute_configuration.collection.AttributeConfigurationCollection' + :rtype :class: `~tamr_unify_client.models.attribute_configuration.collection.AttributeConfigurationCollection` """ alias = self.api_path + "/attributeConfigurations" info = AttributeConfigurationCollection(self.client, api_path=alias) From 3969fae0e3a8f00aa910a456c70ef7b192813c97 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 23 Jul 2019 12:55:59 -0400 Subject: [PATCH 076/632] getting dataset usage --- tamr_unify_client/models/dataset/resource.py | 11 ++++ tamr_unify_client/models/dataset/usage.py | 37 +++++++++++ tamr_unify_client/models/dataset/use.py | 57 +++++++++++++++++ tamr_unify_client/models/project/step.py | 65 ++++++++++++++++++++ 4 files changed, 170 insertions(+) create mode 100644 tamr_unify_client/models/dataset/usage.py create mode 100644 tamr_unify_client/models/dataset/use.py create mode 100644 tamr_unify_client/models/project/step.py diff --git a/tamr_unify_client/models/dataset/resource.py b/tamr_unify_client/models/dataset/resource.py index a0c14a54..3677af58 100644 --- a/tamr_unify_client/models/dataset/resource.py +++ b/tamr_unify_client/models/dataset/resource.py @@ -2,6 +2,7 @@ from tamr_unify_client.models.attribute.collection import AttributeCollection from tamr_unify_client.models.base_resource import BaseResource +from tamr_unify_client.models.dataset.usage import DatasetUsage from tamr_unify_client.models.dataset_profile import DatasetProfile from tamr_unify_client.models.dataset_status import DatasetStatus from tamr_unify_client.models.operation import Operation @@ -139,6 +140,16 @@ def status(self) -> DatasetStatus: self.client, status_json, api_path=self.api_path + "/status" ) + def usage(self): + """Retrieve this dataset's usage by recipes and downstream datasets. + + :return: The dataset's usage. + :rtype: :class:`~tamr_unify_client.models.dataset.usage.DatasetUsage` + """ + alias = self.api_path + "/usage" + usage = self.client.get(alias).successful().json() + return DatasetUsage.from_json(self.client, usage, alias) + def from_geo_features(self, features, geo_attr=None): """Upsert this dataset from a geospatial FeatureCollection or iterable of Features. diff --git a/tamr_unify_client/models/dataset/usage.py b/tamr_unify_client/models/dataset/usage.py new file mode 100644 index 00000000..8a111db0 --- /dev/null +++ b/tamr_unify_client/models/dataset/usage.py @@ -0,0 +1,37 @@ +from tamr_unify_client.models.base_resource import BaseResource +from tamr_unify_client.models.dataset.use import DatasetUse + + +class DatasetUsage(BaseResource): + """ + The usage of a dataset and its downstream dependencies. + + See https://docs.tamr.com/reference#retrieve-downstream-dataset-usage + """ + + @classmethod + def from_json(self, client, resource_json, api_path): + return super().from_data(client, resource_json, api_path) + + @property + def relative_id(self): + """:type: str""" + return self.api_path + + @property + def usage(self): + """:type: :class:`~tamr_unify_client.models.dataset.use.DatasetUse`""" + return DatasetUse(self.client, self._data.get("usage")) + + @property + def dependencies(self): + """:type: list[:class:`~tamr_unify_client.models.dataset.use.DatasetUse`]""" + deps = self._data.get("dependencies") + return [DatasetUse(self.client, dep) for dep in deps] + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"alias={self.api_path!r})" + ) diff --git a/tamr_unify_client/models/dataset/use.py b/tamr_unify_client/models/dataset/use.py new file mode 100644 index 00000000..d3504769 --- /dev/null +++ b/tamr_unify_client/models/dataset/use.py @@ -0,0 +1,57 @@ +from tamr_unify_client.models.project.step import ProjectStep + + +class DatasetUse: + """ + The use of a dataset in project steps. This is not a `BaseResource` because it has no API path + and cannot be directly retrieved or modified. + + See https://docs.tamr.com/reference#retrieve-downstream-dataset-usage + + :param client: Delegate underlying API calls to this client. + :type client: :class:`~tamr_unify_client.Client` + :param data: The JSON body containing usage information. + :type data: :py:class:`dict` + """ + + def __init__(self, client, data): + self.client = client + self._data = data + + @property + def dataset_id(self): + """:type: str""" + return self._data.get("datasetId") + + @property + def dataset_name(self): + """:type: str""" + return self._data.get("datasetName") + + @property + def input_to_project_steps(self): + """:type: list[:class:`~tamr_unify_client.models.project.step.ProjectStep`]""" + steps = self._data.get("inputToProjectSteps") + return [ProjectStep(self.client, step) for step in steps] + + @property + def output_from_project_steps(self): + """:type: list[:class:`~tamr_unify_client.models.project.step.ProjectStep`]""" + steps = self._data.get("outputFromProjectSteps") + return [ProjectStep(self.client, step) for step in steps] + + def dataset(self): + """Retrieves the :class:`~tamr_unify_client.models.dataset.resource.Dataset` this use represents. + + :return: The dataset being used. + :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` + """ + dataset_id = self.dataset_id.split("/")[-1] + return self.client.datasets.by_resource_id(dataset_id) + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"dataset_id={self.dataset_id!r})" + ) diff --git a/tamr_unify_client/models/project/step.py b/tamr_unify_client/models/project/step.py new file mode 100644 index 00000000..41a1a245 --- /dev/null +++ b/tamr_unify_client/models/project/step.py @@ -0,0 +1,65 @@ +class ProjectStep: + """ A step of a Unify project. This is not a `BaseResource` because it has no API path + and cannot be directly retrieved or modified. + + See https://docs.tamr.com/reference#retrieve-downstream-dataset-usage + + :param client: Delegate underlying API calls to this client. + :type client: :class:`~tamr_unify_client.Client` + :param data: The JSON body containing project step information. + :type data: :py:class:`dict` + """ + + def __init__(self, client, data): + self.client = client + self._data = data + + @property + def project_step_id(self): + """:type: str""" + return self._data.get("projectStepId") + + @property + def project_step_name(self): + """:type: str""" + return self._data.get("projectStepName") + + @property + def project_name(self): + """:type: str""" + return self._data.get("projectName") + + @property + def type(self): + """A Unify project type, listed in https://docs.tamr.com/reference#create-a-project. + + :type: str""" + return self._data.get("type") + + def project(self): + """Retrieves the :class:`~tamr_unify_client.models.project.resource.Project` this step is associated with. + + :returns: This step's project. + :rtype: :class:`~tamr_unify_client.models.project.resource.Project` + :raises KeyError: If no project with the specified name is found. + :raises LookupError: If multiple projects with the specified name are found. + """ + name = self.project_name + projects = [p for p in self.client.projects if p.name == name] + + if len(projects) == 0: + raise KeyError(f'No project found with name "{name}"') + elif len(projects) > 1: + raise LookupError(f'Multiple projects found with name "{name}"') + + return projects[0] + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"project_step_id={self.project_step_id!r}, " + f"project_step_name={self.project_step_name!r}, " + f"project_name={self.project_name!r}, " + f"type={self.type!r})" + ) From 623e42cfd62023584c43502445c2a3a46e5bb21f Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 23 Jul 2019 12:56:15 -0400 Subject: [PATCH 077/632] usage tests --- tests/unit/test_dataset_usage.py | 174 +++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 tests/unit/test_dataset_usage.py diff --git a/tests/unit/test_dataset_usage.py b/tests/unit/test_dataset_usage.py new file mode 100644 index 00000000..8cdb61f2 --- /dev/null +++ b/tests/unit/test_dataset_usage.py @@ -0,0 +1,174 @@ +from unittest import TestCase + +import responses + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth +from tamr_unify_client.models.dataset.resource import Dataset +from tamr_unify_client.models.dataset.usage import DatasetUsage +from tamr_unify_client.models.dataset.use import DatasetUse +from tamr_unify_client.models.project.step import ProjectStep + + +class TestUsage(TestCase): + def setUp(self): + auth = UsernamePasswordAuth("username", "password") + self.unify = Client(auth) + + @responses.activate + def test_get_usage(self): + responses.add( + responses.GET, f"{self._base_url}/datasets/1/usage", json=self._usage_json + ) + u = Dataset(self.unify, self._dataset_json).usage() + self.assertEqual(u._data, self._usage_json) + + def test_usage(self): + alias = "datasets/1/usage" + u = DatasetUsage(self.unify, self._usage_json, alias) + self.assertEqual(u.usage._data, self._usage_json["usage"]) + self.assertEqual(u.relative_id, alias) + + udeps = u.dependencies + deps = [DatasetUse(self.unify, dep) for dep in self._usage_json["dependencies"]] + for i in range(len(deps)): + self.assertEqual(deps[i].dataset_id, udeps[i].dataset_id) + + @responses.activate + def test_use(self): + usage_json = self._usage_json["usage"] + u = DatasetUse(self.unify, usage_json) + + responses.add( + responses.GET, f"{self._base_url}/datasets/1", json=self._dataset_json + ) + + self.assertEqual(u.dataset_id, usage_json["datasetId"]) + self.assertEqual(u.dataset_name, usage_json["datasetName"]) + + self.assertEqual(u.output_from_project_steps, []) + inputs = u.input_to_project_steps + step = ProjectStep(self.unify, usage_json["inputToProjectSteps"][0]) + self.assertEqual(len(inputs), 1) + self.assertEqual(repr(inputs[0]), repr(step)) + + dataset = u.dataset() + self.assertEqual(dataset.relative_id, "datasets/1") + + @responses.activate + def test_project_step(self): + step_json = self._usage_json["usage"]["inputToProjectSteps"][0] + step = ProjectStep(self.unify, step_json) + + self.assertEqual(step.project_step_id, step_json["projectStepId"]) + self.assertEqual(step.project_step_name, step_json["projectStepName"]) + self.assertEqual(step.project_name, step_json["projectName"]) + self.assertEqual(step.type, step_json["type"]) + + responses.add( + responses.GET, f"{self._base_url}/projects", json=self._projects_json + ) + project = step.project() + self.assertEqual(project.relative_id, self._projects_json[0]["relativeId"]) + + _base_url = f"http://localhost:9100/api/versioned/v1" + + _dataset_json = { + "id": "unify://unified-data/v1/datasets/1", + "name": "myData.csv", + "description": "", + "version": "321", + "keyAttributeNames": ["pk"], + "tags": [], + "created": { + "username": "admin", + "time": "2019-07-08T20:15:06.818Z", + "version": "4", + }, + "lastModified": { + "username": "admin", + "time": "2019-07-18T17:58:38.453Z", + "version": "6125", + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [], + "externalId": "myData.csv", + } + + _projects_json = [ + { + "id": "unify://unified-data/v1/projects/1", + "name": "My Project", + "description": "Categorization Project", + "type": "CATEGORIZATION", + "unifiedDatasetName": "", + "created": { + "username": "admin", + "time": "2019-07-12T13:08:17.440Z", + "version": "401", + }, + "lastModified": { + "username": "admin", + "time": "2019-07-12T13:08:17.534Z", + "version": "402", + }, + "relativeId": "projects/1", + "externalId": "904bf89e-74ba-45c5-8b4a-5ff913728f66", + } + ] + + _usage_json = { + "usage": { + "datasetId": "unify://unified-data/v1/datasets/1", + "datasetName": "myData.csv", + "inputToProjectSteps": [ + { + "projectStepId": "unify://unified-data/v1/projectSteps/1", + "projectStepName": "My Project-SCHEMA_MAPPING", + "projectName": "My Project", + "type": "SCHEMA_MAPPING", + } + ], + "outputFromProjectSteps": [], + }, + "dependencies": [ + { + "datasetId": "unify://unified-data/v1/datasets/2", + "datasetName": "myData.csv_sample", + "inputToProjectSteps": [], + "outputFromProjectSteps": [], + }, + { + "datasetId": "unify://unified-data/v1/datasets/3", + "datasetName": "My Project - Unified Dataset", + "inputToProjectSteps": [ + { + "projectStepId": "unify://unified-data/v1/projectSteps/2", + "projectStepName": "My Project-SCHEMA_MAPPING_RECOMMENDATIONS", + "projectName": "My Project", + "type": "SCHEMA_MAPPING_RECOMMENDATIONS", + }, + { + "projectStepId": "unify://unified-data/v1/projectSteps/3", + "projectStepName": "My Project-CATEGORIZATION", + "projectName": "My Project", + "type": "CATEGORIZATION", + }, + ], + "outputFromProjectSteps": [ + { + "projectStepId": "unify://unified-data/v1/projectSteps/1", + "projectStepName": "My Project-SCHEMA_MAPPING", + "projectName": "My Project", + "type": "SCHEMA_MAPPING", + }, + { + "projectStepId": "unify://unified-data/v1/projectSteps/2", + "projectStepName": "My Project-SCHEMA_MAPPING_RECOMMENDATIONS", + "projectName": "MY Project", + "type": "SCHEMA_MAPPING_RECOMMENDATIONS", + }, + ], + }, + ], + } From 42c3c2fa6264322fba7502090632fbfef74ab740 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 23 Jul 2019 12:56:25 -0400 Subject: [PATCH 078/632] docs and changelog --- CHANGELOG.md | 1 + docs/developer-interface.rst | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 977a2d26..765d415d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - [#182](https://github.com/Datatamer/unify-client-python/issues/182) Add the ability to refresh estimated pair counts. - [#184](https://github.com/Datatamer/unify-client-python/issues/184) Support for getting published cluster configurations - [#201](https://github.com/Datatamer/unify-client-python/issues/201) Support for refreshing published cluster IDs + - [#181](https://github.com/Datatamer/unify-client-python/issues/181) Support for seeing a dataset's usage ## 0.7.0 **BREAKING CHANGES** diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index 1f71c41b..2bdbe10b 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -43,6 +43,17 @@ Dataset Status .. autoclass:: tamr_unify_client.models.dataset_status.DatasetStatus :members: +Dataset Usage +------------- + +.. autoclass:: tamr_unify_client.models.dataset.usage.DatasetUsage + :members: + +---- + +.. autoclass:: tamr_unify_client.models.dataset.use.DatasetUse + :members: + Datasets -------- @@ -103,6 +114,11 @@ Project ---- +.. autoclass:: tamr_unify_client.models.project.step.ProjectStep + :members: + +---- + .. autoclass:: tamr_unify_client.models.project.estimated_pair_counts.EstimatedPairCounts :members: From 6626bdf38622b34262bcddc17b0673cba72ac080 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 23 Jul 2019 16:03:12 -0400 Subject: [PATCH 079/632] refresh cluster stats --- CHANGELOG.md | 1 + tamr_unify_client/models/project/mastering.py | 14 +++++++++ tests/unit/test_published_clusters.py | 29 ++++++++++++++----- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 765d415d..94421945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - [#184](https://github.com/Datatamer/unify-client-python/issues/184) Support for getting published cluster configurations - [#201](https://github.com/Datatamer/unify-client-python/issues/201) Support for refreshing published cluster IDs - [#181](https://github.com/Datatamer/unify-client-python/issues/181) Support for seeing a dataset's usage + - [#202](https://github.com/Datatamer/unify-client-python/issues/202) Support for refreshing published cluster stats ## 0.7.0 **BREAKING CHANGES** diff --git a/tamr_unify_client/models/project/mastering.py b/tamr_unify_client/models/project/mastering.py index 8ffd65c3..3011f817 100644 --- a/tamr_unify_client/models/project/mastering.py +++ b/tamr_unify_client/models/project/mastering.py @@ -115,6 +115,20 @@ def refresh_published_cluster_ids(self, **options): op = Operation.from_json(self.client, op_json) return op.apply_options(**options) + def refresh_published_cluster_stats(self, **options): + """ + Updates published clusters for this project. + + :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.models.operation.Operation` . + See :func:`~tamr_unify_client.models.operation.Operation.apply_options` . + :returns: The operation to update published clusters. + :rtype: :class:`~tamr_unify_client.models.operation.Operation` + """ + path = self.api_path + "/publishedClusterStats:refresh" + op_json = self.client.post(path).successful().json() + op = Operation.from_json(self.client, op_json) + return op.apply_options(**options) + def estimate_pairs(self): """Returns pair estimate information for a mastering project diff --git a/tests/unit/test_published_clusters.py b/tests/unit/test_published_clusters.py index 0a76ad17..6b117474 100644 --- a/tests/unit/test_published_clusters.py +++ b/tests/unit/test_published_clusters.py @@ -20,11 +20,13 @@ def test_published_clusters(self): datasets_json = [self._published_clusters_json] project_id = "1" - project_url = f"http://localhost:9100/api/versioned/v1/projects/{project_id}" - unified_dataset_url = f"http://localhost:9100/api/versioned/v1/projects/{project_id}/unifiedDataset" - datasets_url = f"http://localhost:9100/api/versioned/v1/datasets" - refresh_url = f"http://localhost:9100/api/versioned/v1/projects/{project_id}/publishedClusters:refresh" - operations_url = f"http://localhost:9100/api/versioned/v1/operations/93" + project_url = f"{self._base_url}/projects/{project_id}" + unified_dataset_url = f"{self._base_url}/projects/{project_id}/unifiedDataset" + datasets_url = f"{self._base_url}/datasets" + refresh_url = ( + f"{self._base_url}/projects/{project_id}/publishedClusters:refresh" + ) + operations_url = f"{self._base_url}/operations/93" responses.add(responses.GET, project_url, json=self._project_config_json) responses.add( @@ -44,7 +46,7 @@ def test_published_clusters(self): @responses.activate def test_published_clusters_configuration(self): path = "projects/1/publishedClustersConfiguration" - config_url = f"http://localhost:9100/api/versioned/v1/{path}" + config_url = f"{self._base_url}/{path}" responses.add(responses.GET, config_url, json=self._config_json) p = Project(self.unify, self._project_config_json).as_mastering() @@ -60,8 +62,7 @@ def test_published_clusters_configuration(self): @responses.activate def test_refresh_ids(self): - path = "projects/1/allPublishedClusterIds:refresh" - refresh_url = f"http://localhost:9100/api/versioned/v1/{path}" + refresh_url = f"{self._base_url}/projects/1/allPublishedClusterIds:refresh" responses.add(responses.POST, refresh_url, json=self._operations_json) p = Project(self.unify, self._project_config_json).as_mastering() @@ -69,6 +70,18 @@ def test_refresh_ids(self): self.assertEqual(op.resource_id, self._operations_json["id"]) self.assertTrue(op.succeeded()) + @responses.activate + def test_refresh_stats(self): + refresh_url = f"{self._base_url}/projects/1/publishedClusterStats:refresh" + responses.add(responses.POST, refresh_url, json=self._operations_json) + + p = Project(self.unify, self._project_config_json).as_mastering() + op = p.refresh_published_cluster_stats() + self.assertEqual(op.resource_id, self._operations_json["id"]) + self.assertTrue(op.succeeded()) + + _base_url = "http://localhost:9100/api/versioned/v1" + _project_config_json = { "id": "unify://unified-data/v1/projects/1", "name": "Project_1", From ce73864ae528e5cd9936188f070f3587728b6d78 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Wed, 24 Jul 2019 13:44:15 -0400 Subject: [PATCH 080/632] convert cluster stats and id to return dataset --- tamr_unify_client/models/project/mastering.py | 50 ++++++++++-------- tests/unit/test_published_clusters.py | 51 +++++++++++++++++-- 2 files changed, 74 insertions(+), 27 deletions(-) diff --git a/tamr_unify_client/models/project/mastering.py b/tamr_unify_client/models/project/mastering.py index 3011f817..869edacf 100644 --- a/tamr_unify_client/models/project/mastering.py +++ b/tamr_unify_client/models/project/mastering.py @@ -101,33 +101,39 @@ def published_clusters_configuration(self): self.client, resource_json, alias ) - def refresh_published_cluster_ids(self, **options): - """ - Updates published clusters for this project. + def published_cluster_ids(self): + """Retrieves published cluster IDs for this project. - :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.models.operation.Operation` . - See :func:`~tamr_unify_client.models.operation.Operation.apply_options` . - :returns: The operation to update published clusters. - :rtype: :class:`~tamr_unify_client.models.operation.Operation` + :returns: The published cluster ID dataset. + :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` """ - path = self.api_path + "/allPublishedClusterIds:refresh" - op_json = self.client.post(path).successful().json() - op = Operation.from_json(self.client, op_json) - return op.apply_options(**options) + # Replace this workaround with a direct API call once API + # is fixed. APIs that need to work are: fetching the dataset and + # being able to call refresh on resulting dataset. Until then, we grab + # the dataset by constructing its name from the corresponding Unified Dataset's name + unified_dataset = self.unified_dataset() + name = unified_dataset.name + "_dedup_all_persistent_ids" + dataset = self.client.datasets.by_name(name) - def refresh_published_cluster_stats(self, **options): - """ - Updates published clusters for this project. + path = self.api_path + "/allPublishedClusterIds" + return Dataset.from_json(self.client, dataset._data, path) - :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.models.operation.Operation` . - See :func:`~tamr_unify_client.models.operation.Operation.apply_options` . - :returns: The operation to update published clusters. - :rtype: :class:`~tamr_unify_client.models.operation.Operation` + def published_cluster_stats(self): + """Retrieves published cluster stats for this project. + + :returns: The published cluster stats dataset. + :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` """ - path = self.api_path + "/publishedClusterStats:refresh" - op_json = self.client.post(path).successful().json() - op = Operation.from_json(self.client, op_json) - return op.apply_options(**options) + # Replace this workaround with a direct API call once API + # is fixed. APIs that need to work are: fetching the dataset and + # being able to call refresh on resulting dataset. Until then, we grab + # the dataset by constructing its name from the corresponding Unified Dataset's name + unified_dataset = self.unified_dataset() + name = unified_dataset.name + "_dedup_published_cluster_stats" + dataset = self.client.datasets.by_name(name) + + path = self.api_path + "/publishedClusterStats" + return Dataset.from_json(self.client, dataset._data, path) def estimate_pairs(self): """Returns pair estimate information for a mastering project diff --git a/tests/unit/test_published_clusters.py b/tests/unit/test_published_clusters.py index 6b117474..dd1b3f07 100644 --- a/tests/unit/test_published_clusters.py +++ b/tests/unit/test_published_clusters.py @@ -17,9 +17,7 @@ def setUp(self): @responses.activate def test_published_clusters(self): - datasets_json = [self._published_clusters_json] project_id = "1" - project_url = f"{self._base_url}/projects/{project_id}" unified_dataset_url = f"{self._base_url}/projects/{project_id}/unifiedDataset" datasets_url = f"{self._base_url}/datasets" @@ -32,7 +30,7 @@ def test_published_clusters(self): responses.add( responses.GET, unified_dataset_url, json=self._unified_dataset_json ) - responses.add(responses.GET, datasets_url, json=datasets_json) + responses.add(responses.GET, datasets_url, json=self._datasets_json) responses.add(responses.POST, refresh_url, json=self._refresh_json) responses.add(responses.GET, operations_url, json=self._operations_json) project = self.unify.projects.by_resource_id(project_id) @@ -62,21 +60,39 @@ def test_published_clusters_configuration(self): @responses.activate def test_refresh_ids(self): + unified_dataset_url = f"{self._base_url}/projects/1/unifiedDataset" + datasets_url = f"{self._base_url}/datasets" refresh_url = f"{self._base_url}/projects/1/allPublishedClusterIds:refresh" + + responses.add( + responses.GET, unified_dataset_url, json=self._unified_dataset_json + ) + responses.add(responses.GET, datasets_url, json=self._datasets_json) responses.add(responses.POST, refresh_url, json=self._operations_json) p = Project(self.unify, self._project_config_json).as_mastering() - op = p.refresh_published_cluster_ids() + d = p.published_cluster_ids() + + op = d.refresh() self.assertEqual(op.resource_id, self._operations_json["id"]) self.assertTrue(op.succeeded()) @responses.activate def test_refresh_stats(self): + unified_dataset_url = f"{self._base_url}/projects/1/unifiedDataset" + datasets_url = f"{self._base_url}/datasets" refresh_url = f"{self._base_url}/projects/1/publishedClusterStats:refresh" + + responses.add( + responses.GET, unified_dataset_url, json=self._unified_dataset_json + ) + responses.add(responses.GET, datasets_url, json=self._datasets_json) responses.add(responses.POST, refresh_url, json=self._operations_json) p = Project(self.unify, self._project_config_json).as_mastering() - op = p.refresh_published_cluster_stats() + d = p.published_cluster_stats() + + op = d.refresh() self.assertEqual(op.resource_id, self._operations_json["id"]) self.assertTrue(op.succeeded()) @@ -109,6 +125,31 @@ def test_refresh_stats(self): "externalId": "Project_1_unified_dataset_dedup_published_clusters", } + _published_stats_json = { + "id": "unify://unified-data/v1/datasets/33", + "name": "Project_1_unified_dataset_dedup_published_cluster_stats", + "description": "Published cluster stats", + "version": "253", + "relativeId": "datasets/33", + "externalId": "Project_1_unified_dataset_dedup_published_cluster_stats", + } + + _published_ids_json = { + "id": "unify://unified-data/v1/datasets/34", + "name": "Project_1_unified_dataset_dedup_all_persistent_ids", + "description": "All previously and currently published cluster IDs", + "version": "253", + "relativeId": "datasets/34", + "externalId": "Project_1_unified_dataset_dedup_all_persistent_ids", + } + + _datasets_json = [ + _unified_dataset_json, + _published_clusters_json, + _published_stats_json, + _published_ids_json, + ] + _refresh_json = { "id": "93", "type": "SPARK", From 51d8fb0674734bdfd26ea5b83afdb8296a292560 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 23 Jul 2019 16:46:27 -0400 Subject: [PATCH 081/632] add simplejson to dependencies --- poetry.lock | 11 ++++++++++- pyproject.toml | 1 + 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 1b5ac478..69f704b0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -313,6 +313,14 @@ version = "0.10.6" requests = ">=2.0" six = "*" +[[package]] +category = "main" +description = "Simple, fast, extensible JSON encoder/decoder for Python" +name = "simplejson" +optional = false +python-versions = ">=2.5, !=3.0.*, !=3.1.*, !=3.2.*" +version = "3.16.0" + [[package]] category = "dev" description = "Python 2 and 3 compatibility utilities" @@ -448,7 +456,7 @@ python-versions = ">=2.7" version = "0.5.1" [metadata] -content-hash = "86be94bbec36a35672dc8144eb0963abbe6824caaabf777eb9d5eece92b047ee" +content-hash = "0772a1be9e411424ce4a918a8d3e3dfa5627aa0f308ae6dd857c9fde6e3dced6" python-versions = "^3.6" [metadata.hashes] @@ -484,6 +492,7 @@ pytest = ["6032845e68a17a96e8da3088037f899b56357769a724122056265ca2ea1890ee", "b pytz = ["303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda", "d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141"] requests = ["11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", "9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"] responses = ["502d9c0c8008439cfcdef7e251f507fcfdd503b56e8c0c87c3c3e3393953f790", "97193c0183d63fba8cd3a041c75464e4b09ea0aff6328800d1546598567dde0b"] +simplejson = ["067a7177ddfa32e1483ba5169ebea1bc2ea27f224853211ca669325648ca5642", "2b8cb601d9ba0381499db719ccc9dfbb2fbd16013f5ff096b1a68a4775576a04", "2c139daf167b96f21542248f8e0a06596c9b9a7a41c162cc5c9ee9f3833c93cd", "2fc546e6af49fb45b93bbe878dea4c48edc34083729c0abd09981fe55bdf7f91", "354fa32b02885e6dae925f1b5bbf842c333c1e11ea5453ddd67309dc31fdb40a", "37e685986cf6f8144607f90340cff72d36acf654f3653a6c47b84c5c38d00df7", "3af610ee72efbe644e19d5eaad575c73fb83026192114e5f6719f4901097fce2", "3b919fc9cf508f13b929a9b274c40786036b31ad28657819b3b9ba44ba651f50", "3dd289368bbd064974d9a5961101f080e939cbe051e6689a193c99fb6e9ac89b", "491de7acc423e871a814500eb2dcea8aa66c4a4b1b4825d18f756cdf58e370cb", "495511fe5f10ccf4e3ed4fc0c48318f533654db6c47ecbc970b4ed215c791968", "65b41a5cda006cfa7c66eabbcf96aa704a6be2a5856095b9e2fd8c293bad2b46", "6c3258ffff58712818a233b9737fe4be943d306c40cf63d14ddc82ba563f483a", "75e3f0b12c28945c08f54350d91e624f8dd580ab74fd4f1bbea54bc6b0165610", "79b129fe65fdf3765440f7a73edaffc89ae9e7885d4e2adafe6aa37913a00fbb", "b1f329139ba647a9548aa05fb95d046b4a677643070dc2afc05fa2e975d09ca5", "c206f47cbf9f32b573c9885f0ec813d2622976cf5effcf7e472344bc2e020ac1", "d8e238f20bcf70063ee8691d4a72162bcec1f4c38f83c93e6851e72ad545dabb", "ee9625fc8ee164902dfbb0ff932b26df112da9f871c32f0f9c1bcf20c350fe2a", "fb2530b53c28f0d4d84990e945c2ebb470edb469d63e389bf02ff409012fe7c5", "feadb95170e45f439455354904768608e356c5b174ca30b3d11b0e3f24b5c0df"] six = ["3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", "d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"] snowballstemmer = ["919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128", "9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89"] sphinx = ["2c5becc0fd6706dc0aeb4703f9f1f8a1d1eecacf02e9ac5943cbae48b11e5e42", "7a359a91fb04054ec77d68ff97cb8728f8cc322e25f22dc94299d67e0e6a7123"] diff --git a/pyproject.toml b/pyproject.toml index a1cee138..b3daa5b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ classifiers = [ [tool.poetry.dependencies] python = "^3.6" requests = "^2.22" +simplejson = "^3.16" [tool.poetry.dev-dependencies] Sphinx = "^2.1" From 82b9846cd36b4e8177a24f0a655b9c13a1740927 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Wed, 24 Jul 2019 16:00:57 -0400 Subject: [PATCH 082/632] using simplejson to update records --- CHANGELOG.md | 1 + tamr_unify_client/models/dataset/resource.py | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 765d415d..9a388024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - [#184](https://github.com/Datatamer/unify-client-python/issues/184) Support for getting published cluster configurations - [#201](https://github.com/Datatamer/unify-client-python/issues/201) Support for refreshing published cluster IDs - [#181](https://github.com/Datatamer/unify-client-python/issues/181) Support for seeing a dataset's usage + - [#194](https://github.com/Datatamer/unify-client-python/issues/194) Allowing update of records containing NaN ## 0.7.0 **BREAKING CHANGES** diff --git a/tamr_unify_client/models/dataset/resource.py b/tamr_unify_client/models/dataset/resource.py index 3677af58..0fd8ab40 100644 --- a/tamr_unify_client/models/dataset/resource.py +++ b/tamr_unify_client/models/dataset/resource.py @@ -1,4 +1,4 @@ -import json +import simplejson as json from tamr_unify_client.models.attribute.collection import AttributeCollection from tamr_unify_client.models.base_resource import BaseResource @@ -55,18 +55,20 @@ def attributes(self): alias = self.api_path + "/attributes" return AttributeCollection(self.client, alias) - def update_records(self, records): + def update_records(self, records, **json_args): """Send a batch of record creations/updates/deletions to this dataset. :param records: Each record should be formatted as specified in the `Public Docs for Dataset updates `_. :type records: iterable[dict] + :param `**json_args`: Arguments to pass to the JSON `dumps` function, as documented `here `_. + Some of these, such as `indent`, may not work with Unify. :returns: JSON response body from server. :rtype: :py:class:`dict` """ def _stringify_updates(updates): for update in updates: - yield json.dumps(update).encode("utf-8") + yield json.dumps(update, **json_args).encode("utf-8") return ( self.client.post( From db117de77b7ebfe4cfc396cfe49901eb629a38d0 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Wed, 24 Jul 2019 17:06:28 -0400 Subject: [PATCH 083/632] test update records --- tests/unit/test_dataset_records.py | 113 +++++++++++++++++++++++++---- 1 file changed, 98 insertions(+), 15 deletions(-) diff --git a/tests/unit/test_dataset_records.py b/tests/unit/test_dataset_records.py index 78536854..47ab0f85 100644 --- a/tests/unit/test_dataset_records.py +++ b/tests/unit/test_dataset_records.py @@ -1,21 +1,104 @@ +from functools import partial +from unittest import TestCase + +from requests.exceptions import HTTPError import responses +import simplejson from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth -@responses.activate -def test_dataset_records(): - dataset_id = "1" - dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/{dataset_id}" - records_url = f"{dataset_url}/records" - responses.add(responses.GET, dataset_url, json={}) - responses.add( - responses.GET, records_url, body='{"attribute1": 1}\n{"attribute1": 2}' - ) - auth = UsernamePasswordAuth("username", "password") - unify = Client(auth) - - dataset = unify.datasets.by_resource_id(dataset_id) - records = list(dataset.records()) - assert records == [{"attribute1": 1}, {"attribute1": 2}] +class TestDatasetRecords(TestCase): + def setUp(self): + auth = UsernamePasswordAuth("username", "password") + self.unify = Client(auth) + + @responses.activate + def test_get(self): + records_url = f"{self._dataset_url}/records" + responses.add(responses.GET, self._dataset_url, json={}) + responses.add( + responses.GET, + records_url, + body="\n".join([simplejson.dumps(l) for l in self._records_json]), + ) + + dataset = self.unify.datasets.by_resource_id(self._dataset_id) + records = list(dataset.records()) + self.assertListEqual(records, self._records_json) + + @responses.activate + def test_update(self): + def create_callback(request, snoop): + snoop["payload"] = list(request.body) + return 200, {}, simplejson.dumps(self._response_json) + + responses.add(responses.GET, self._dataset_url, json={}) + dataset = self.unify.datasets.by_resource_id(self._dataset_id) + + records_url = f"{self._dataset_url}:updateRecords" + updates = TestDatasetRecords.records_to_updates(self._records_json) + snoop = {} + responses.add_callback( + responses.POST, records_url, partial(create_callback, snoop=snoop) + ) + + response = dataset.update_records(updates) + self.assertEqual(response, self._response_json) + self.assertEqual(snoop["payload"], TestDatasetRecords.stringify(updates, False)) + + @responses.activate + def test_nan_update(self): + def create_callback(request, snoop, status): + snoop["payload"] = list(request.body) + return status, {}, simplejson.dumps(self._response_json) + + responses.add(responses.GET, self._dataset_url, json={}) + dataset = self.unify.datasets.by_resource_id(self._dataset_id) + + records_url = f"{self._dataset_url}:updateRecords" + updates = TestDatasetRecords.records_to_updates(self._nan_records_json) + snoop = {} + + responses.add_callback( + responses.POST, + records_url, + partial(create_callback, snoop=snoop, status=400), + ) + responses.add_callback( + responses.POST, + records_url, + partial(create_callback, snoop=snoop, status=200), + ) + + self.assertRaises(HTTPError, lambda: dataset.update_records(updates)) + self.assertEqual(snoop["payload"], TestDatasetRecords.stringify(updates, False)) + + response = dataset.update_records(updates, ignore_nan=True) + self.assertEqual(response, self._response_json) + self.assertEqual(snoop["payload"], TestDatasetRecords.stringify(updates, True)) + + @staticmethod + def records_to_updates(records): + return [ + {"action": "CREATE", "recordId": str(i), "record": records[i]} + for i in range(len(records)) + ] + + @staticmethod + def stringify(updates, ignore_nan): + return [ + simplejson.dumps(u, ignore_nan=ignore_nan).encode("utf-8") for u in updates + ] + + _dataset_id = "1" + _dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/{_dataset_id}" + + _records_json = [{"attribute1": 1}, {"attribute1": 2}] + _nan_records_json = [{"attribute1": float("nan")}, {"attribute1": float("nan")}] + _response_json = { + "numCommandsProcessed": 2, + "allCommandsSucceeded": True, + "validationErrors": [], + } From 9bab973751433957ca8079a7a1bad35dca80bae1 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 25 Jul 2019 11:17:56 -0400 Subject: [PATCH 084/632] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a388024..01f8f806 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - [#184](https://github.com/Datatamer/unify-client-python/issues/184) Support for getting published cluster configurations - [#201](https://github.com/Datatamer/unify-client-python/issues/201) Support for refreshing published cluster IDs - [#181](https://github.com/Datatamer/unify-client-python/issues/181) Support for seeing a dataset's usage - - [#194](https://github.com/Datatamer/unify-client-python/issues/194) Allowing update of records containing NaN + - [#194](https://github.com/Datatamer/unify-client-python/issues/194) Allowing additional JSON parameters to be used for update of records ## 0.7.0 **BREAKING CHANGES** From f87ab63f697ae910c1028a1884527832ac9f3af8 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 26 Jul 2019 09:45:04 -0400 Subject: [PATCH 085/632] speeding up tests --- CHANGELOG.md | 3 +++ tests/unit/test_dataset_profile.py | 2 +- tests/unit/test_pair_counts.py | 2 +- tests/unit/test_published_clusters.py | 6 +++--- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45934418..02fb94cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ - [#202](https://github.com/Datatamer/unify-client-python/issues/202) Support for refreshing published cluster stats - [#194](https://github.com/Datatamer/unify-client-python/issues/194) Allowing additional JSON parameters to be used for update of records + **BUG FIXES** + - [#212](https://github.com/Datatamer/unify-client-python/issues/212) Sped up slow running tests + ## 0.7.0 **BREAKING CHANGES** - [#156](https://github.com/Datatamer/unify-client-python/issues/156) Fetch Dataset profile, even if out of date. diff --git a/tests/unit/test_dataset_profile.py b/tests/unit/test_dataset_profile.py index 6053294e..e662d205 100644 --- a/tests/unit/test_dataset_profile.py +++ b/tests/unit/test_dataset_profile.py @@ -51,7 +51,7 @@ def test_profile_refresh(self): dataset = client.datasets.by_resource_id(dataset_id) profile = dataset.profile() - op = profile.refresh() + op = profile.refresh(poll_interval_seconds=0) self.assertTrue(op.succeeded()) @responses.activate diff --git a/tests/unit/test_pair_counts.py b/tests/unit/test_pair_counts.py index 8f72bed7..bc6dbfa9 100644 --- a/tests/unit/test_pair_counts.py +++ b/tests/unit/test_pair_counts.py @@ -53,7 +53,7 @@ def test_refresh(self): estimate = EstimatedPairCounts.from_json( self.unify, self._estimate_json, self._api_path ) - generated = estimate.refresh() + generated = estimate.refresh(poll_interval_seconds=0) created = Operation.from_json(self.unify, updated) self.assertEqual(repr(generated), repr(created)) diff --git a/tests/unit/test_published_clusters.py b/tests/unit/test_published_clusters.py index dd1b3f07..ad4c557d 100644 --- a/tests/unit/test_published_clusters.py +++ b/tests/unit/test_published_clusters.py @@ -35,7 +35,7 @@ def test_published_clusters(self): responses.add(responses.GET, operations_url, json=self._operations_json) project = self.unify.projects.by_resource_id(project_id) actual_published_clusters_dataset = project.as_mastering().published_clusters() - actual_published_clusters_dataset.refresh() + actual_published_clusters_dataset.refresh(poll_interval_seconds=0) self.assertEqual( actual_published_clusters_dataset.name, self._published_clusters_json["name"], @@ -73,7 +73,7 @@ def test_refresh_ids(self): p = Project(self.unify, self._project_config_json).as_mastering() d = p.published_cluster_ids() - op = d.refresh() + op = d.refresh(poll_interval_seconds=0) self.assertEqual(op.resource_id, self._operations_json["id"]) self.assertTrue(op.succeeded()) @@ -92,7 +92,7 @@ def test_refresh_stats(self): p = Project(self.unify, self._project_config_json).as_mastering() d = p.published_cluster_stats() - op = d.refresh() + op = d.refresh(poll_interval_seconds=0) self.assertEqual(op.resource_id, self._operations_json["id"]) self.assertTrue(op.succeeded()) From f59551a8242dea244524910438ec0e2091c1852c Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 26 Jul 2019 15:45:46 -0400 Subject: [PATCH 086/632] upsert records convenience method --- CHANGELOG.md | 1 + tamr_unify_client/models/dataset/resource.py | 18 +++++++++++++++ tests/unit/test_dataset_records.py | 24 ++++++++++++++++++-- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02fb94cf..57ce3268 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - [#181](https://github.com/Datatamer/unify-client-python/issues/181) Support for seeing a dataset's usage - [#202](https://github.com/Datatamer/unify-client-python/issues/202) Support for refreshing published cluster stats - [#194](https://github.com/Datatamer/unify-client-python/issues/194) Allowing additional JSON parameters to be used for update of records + - [#205](https://github.com/Datatamer/unify-client-python/issues/205) Update a dataset's records with records rather than record updates **BUG FIXES** - [#212](https://github.com/Datatamer/unify-client-python/issues/212) Sped up slow running tests diff --git a/tamr_unify_client/models/dataset/resource.py b/tamr_unify_client/models/dataset/resource.py index 0fd8ab40..ffb86cfa 100644 --- a/tamr_unify_client/models/dataset/resource.py +++ b/tamr_unify_client/models/dataset/resource.py @@ -80,6 +80,24 @@ def _stringify_updates(updates): .json() ) + def upsert_records(self, records, primary_key_name, **json_args): + """Converts the records into update commands and calls :func:`~tamr_unify_client.models.dataset.resource.Dataset.update_records` + + :param records: The records to update, as dictionaries. + :type records: iterable[dict] + :param primary_key_name: The name of the primary key for these records, which must be a key in each record dictionary. + :type primary_key_name: str + :param `**json_args`: Arguments to pass to the JSON `dumps` function, as documented `here `_. + Some of these, such as `indent`, may not work with Unify. + :return: JSON response body from the server. + :rtype: dict + """ + updates = ( + {"action": "CREATE", "recordId": record[primary_key_name], "record": record} + for record in records + ) + return self.update_records(updates, **json_args) + def refresh(self, **options): """Brings dataset up-to-date if needed, taking whatever actions are required. :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.models.operation.Operation` . diff --git a/tests/unit/test_dataset_records.py b/tests/unit/test_dataset_records.py index 47ab0f85..457712f8 100644 --- a/tests/unit/test_dataset_records.py +++ b/tests/unit/test_dataset_records.py @@ -79,11 +79,31 @@ def create_callback(request, snoop, status): self.assertEqual(response, self._response_json) self.assertEqual(snoop["payload"], TestDatasetRecords.stringify(updates, True)) + @responses.activate + def test_upsert(self): + def create_callback(request, snoop): + snoop["payload"] = list(request.body) + return 200, {}, simplejson.dumps(self._response_json) + + responses.add(responses.GET, self._dataset_url, json={}) + dataset = self.unify.datasets.by_resource_id(self._dataset_id) + + records_url = f"{self._dataset_url}:updateRecords" + updates = TestDatasetRecords.records_to_updates(self._records_json) + snoop = {} + responses.add_callback( + responses.POST, records_url, partial(create_callback, snoop=snoop) + ) + + response = dataset.upsert_records(self._records_json, "attribute1") + self.assertEqual(response, self._response_json) + self.assertEqual(snoop["payload"], TestDatasetRecords.stringify(updates, False)) + @staticmethod def records_to_updates(records): return [ - {"action": "CREATE", "recordId": str(i), "record": records[i]} - for i in range(len(records)) + {"action": "CREATE", "recordId": i, "record": record} + for i, record in enumerate(records, start=1) ] @staticmethod From 73ba8f8545c576e7f55c4dd595f2bb248a0cdc47 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Mon, 29 Jul 2019 10:56:20 -0400 Subject: [PATCH 087/632] delete records convenience method --- CHANGELOG.md | 1 + tamr_unify_client/models/dataset/resource.py | 16 ++++++++++++ tests/unit/test_dataset_records.py | 27 ++++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57ce3268..05055c6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - [#202](https://github.com/Datatamer/unify-client-python/issues/202) Support for refreshing published cluster stats - [#194](https://github.com/Datatamer/unify-client-python/issues/194) Allowing additional JSON parameters to be used for update of records - [#205](https://github.com/Datatamer/unify-client-python/issues/205) Update a dataset's records with records rather than record updates + - Delete records from a dataset by providing records rather than record updates **BUG FIXES** - [#212](https://github.com/Datatamer/unify-client-python/issues/212) Sped up slow running tests diff --git a/tamr_unify_client/models/dataset/resource.py b/tamr_unify_client/models/dataset/resource.py index ffb86cfa..95d4174b 100644 --- a/tamr_unify_client/models/dataset/resource.py +++ b/tamr_unify_client/models/dataset/resource.py @@ -98,6 +98,22 @@ def upsert_records(self, records, primary_key_name, **json_args): ) return self.update_records(updates, **json_args) + def delete_records(self, records, primary_key_name): + """Converts the records into delete commands and calls :func:`~tamr_unify_client.models.dataset.resource.Dataset.update_records` + + :param records: The records to delete, as dictionaries. + :type records: iterable[dict] + :param primary_key_name: The name of the primary key for these records, which must be a key in each record dictionary. + :type primary_key_name: str + :return: JSON response body from the server. + :rtype: dict + """ + updates = ( + {"action": "DELETE", "recordId": record[primary_key_name]} + for record in records + ) + return self.update_records(updates) + def refresh(self, **options): """Brings dataset up-to-date if needed, taking whatever actions are required. :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.models.operation.Operation` . diff --git a/tests/unit/test_dataset_records.py b/tests/unit/test_dataset_records.py index 457712f8..1a844119 100644 --- a/tests/unit/test_dataset_records.py +++ b/tests/unit/test_dataset_records.py @@ -99,6 +99,33 @@ def create_callback(request, snoop): self.assertEqual(response, self._response_json) self.assertEqual(snoop["payload"], TestDatasetRecords.stringify(updates, False)) + @responses.activate + def test_delete(self): + def create_callback(request, snoop): + snoop["payload"] = list(request.body) + return 200, {}, simplejson.dumps(self._response_json) + + responses.add(responses.GET, self._dataset_url, json={}) + dataset = self.unify.datasets.by_resource_id(self._dataset_id) + + records_url = f"{self._dataset_url}:updateRecords" + deletes = TestDatasetRecords.records_to_deletes(self._records_json) + snoop = {} + responses.add_callback( + responses.POST, records_url, partial(create_callback, snoop=snoop) + ) + + response = dataset.delete_records(self._records_json, "attribute1") + self.assertEqual(response, self._response_json) + self.assertEqual(snoop["payload"], TestDatasetRecords.stringify(deletes, False)) + + @staticmethod + def records_to_deletes(records): + return [ + {"action": "DELETE", "recordId": i} + for i, record in enumerate(records, start=1) + ] + @staticmethod def records_to_updates(records): return [ From 927ef3d615c713cacdb180460c8eaae74d6fc518 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Mon, 29 Jul 2019 11:09:24 -0400 Subject: [PATCH 088/632] deprecate update_records --- CHANGELOG.md | 1 + tamr_unify_client/models/dataset/resource.py | 23 ++++++++++---------- tests/unit/test_dataset_records.py | 6 ++--- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05055c6a..e43168a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - [#175](https://github.com/Datatamer/unify-client-python/issues/175) `AttributeCollection` no longer has a `from_json` method or a `data` parameter in its constructor - `AttributeType` no longer inherits from `BaseResource` (no API path), removing its `from_json` method and `relative_id` property - The type of `AttributeType`'s `attributes` property is now a `list` of `SubAttribute`s, which are identical to `Attribute`s except they lack an API path + - The `Dataset` function `update_records` has been renamed `_update_records` as the convenience functions `upsert_records` and `delete_records` now exist. **NEW FEATURES** - [#174](https://github.com/Datatamer/unify-client-python/issues/174) Get and create taxonomy categories diff --git a/tamr_unify_client/models/dataset/resource.py b/tamr_unify_client/models/dataset/resource.py index 95d4174b..aba82876 100644 --- a/tamr_unify_client/models/dataset/resource.py +++ b/tamr_unify_client/models/dataset/resource.py @@ -55,8 +55,10 @@ def attributes(self): alias = self.api_path + "/attributes" return AttributeCollection(self.client, alias) - def update_records(self, records, **json_args): + def _update_records(self, updates, **json_args): """Send a batch of record creations/updates/deletions to this dataset. + You probably want to use :func:`~tamr_unify_client.models.dataset.resource.Dataset.upsert_records` + or :func:`~tamr_unify_client.models.dataset.resource.Dataset.delete_records` instead. :param records: Each record should be formatted as specified in the `Public Docs for Dataset updates `_. :type records: iterable[dict] @@ -65,23 +67,22 @@ def update_records(self, records, **json_args): :returns: JSON response body from server. :rtype: :py:class:`dict` """ - - def _stringify_updates(updates): - for update in updates: - yield json.dumps(update, **json_args).encode("utf-8") + stringified_updates = ( + json.dumps(update, **json_args).encode("utf-8") for update in updates + ) return ( self.client.post( self.api_path + ":updateRecords", headers={"Content-Encoding": "utf-8"}, - data=_stringify_updates(records), + data=stringified_updates, ) .successful() .json() ) def upsert_records(self, records, primary_key_name, **json_args): - """Converts the records into update commands and calls :func:`~tamr_unify_client.models.dataset.resource.Dataset.update_records` + """Creates or updates the specified records. :param records: The records to update, as dictionaries. :type records: iterable[dict] @@ -96,10 +97,10 @@ def upsert_records(self, records, primary_key_name, **json_args): {"action": "CREATE", "recordId": record[primary_key_name], "record": record} for record in records ) - return self.update_records(updates, **json_args) + return self._update_records(updates, **json_args) def delete_records(self, records, primary_key_name): - """Converts the records into delete commands and calls :func:`~tamr_unify_client.models.dataset.resource.Dataset.update_records` + """Deletes the specified records. :param records: The records to delete, as dictionaries. :type records: iterable[dict] @@ -112,7 +113,7 @@ def delete_records(self, records, primary_key_name): {"action": "DELETE", "recordId": record[primary_key_name]} for record in records ) - return self.update_records(updates) + return self._update_records(updates) def refresh(self, **options): """Brings dataset up-to-date if needed, taking whatever actions are required. @@ -221,7 +222,7 @@ def from_geo_features(self, features, geo_attr=None): if geo_attr is None: geo_attr = self._geo_attr - self.update_records( + self._update_records( self._features_to_updates(features, record_id, key_attrs, geo_attr) ) diff --git a/tests/unit/test_dataset_records.py b/tests/unit/test_dataset_records.py index 1a844119..85b1b9bb 100644 --- a/tests/unit/test_dataset_records.py +++ b/tests/unit/test_dataset_records.py @@ -44,7 +44,7 @@ def create_callback(request, snoop): responses.POST, records_url, partial(create_callback, snoop=snoop) ) - response = dataset.update_records(updates) + response = dataset._update_records(updates) self.assertEqual(response, self._response_json) self.assertEqual(snoop["payload"], TestDatasetRecords.stringify(updates, False)) @@ -72,10 +72,10 @@ def create_callback(request, snoop, status): partial(create_callback, snoop=snoop, status=200), ) - self.assertRaises(HTTPError, lambda: dataset.update_records(updates)) + self.assertRaises(HTTPError, lambda: dataset._update_records(updates)) self.assertEqual(snoop["payload"], TestDatasetRecords.stringify(updates, False)) - response = dataset.update_records(updates, ignore_nan=True) + response = dataset._update_records(updates, ignore_nan=True) self.assertEqual(response, self._response_json) self.assertEqual(snoop["payload"], TestDatasetRecords.stringify(updates, True)) From b8fcf2f4ea420bb1dbdbf8280339a82b07a608c1 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Mon, 29 Jul 2019 13:24:59 -0400 Subject: [PATCH 089/632] delete records by id --- tamr_unify_client/models/dataset/resource.py | 16 +++++++++++---- tests/unit/test_dataset_records.py | 21 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/tamr_unify_client/models/dataset/resource.py b/tamr_unify_client/models/dataset/resource.py index aba82876..b5d27328 100644 --- a/tamr_unify_client/models/dataset/resource.py +++ b/tamr_unify_client/models/dataset/resource.py @@ -109,10 +109,18 @@ def delete_records(self, records, primary_key_name): :return: JSON response body from the server. :rtype: dict """ - updates = ( - {"action": "DELETE", "recordId": record[primary_key_name]} - for record in records - ) + ids = (record[primary_key_name] for record in records) + return self.delete_records_by_id(ids) + + def delete_records_by_id(self, record_ids): + """Deletes the specified records. + + :param record_ids: The IDs of the records to delete. + :type record_ids: iterable + :return: JSON response body from the server. + :rtype: dict + """ + updates = ({"action": "DELETE", "recordId": rid} for rid in record_ids) return self._update_records(updates) def refresh(self, **options): diff --git a/tests/unit/test_dataset_records.py b/tests/unit/test_dataset_records.py index 85b1b9bb..f6483c3e 100644 --- a/tests/unit/test_dataset_records.py +++ b/tests/unit/test_dataset_records.py @@ -119,6 +119,27 @@ def create_callback(request, snoop): self.assertEqual(response, self._response_json) self.assertEqual(snoop["payload"], TestDatasetRecords.stringify(deletes, False)) + @responses.activate + def test_delete_ids(self): + def create_callback(request, snoop): + snoop["payload"] = list(request.body) + return 200, {}, simplejson.dumps(self._response_json) + + responses.add(responses.GET, self._dataset_url, json={}) + dataset = self.unify.datasets.by_resource_id(self._dataset_id) + + records_url = f"{self._dataset_url}:updateRecords" + deletes = TestDatasetRecords.records_to_deletes(self._records_json) + snoop = {} + responses.add_callback( + responses.POST, records_url, partial(create_callback, snoop=snoop) + ) + + ids = [r["attribute1"] for r in self._records_json] + response = dataset.delete_records_by_id(ids) + self.assertEqual(response, self._response_json) + self.assertEqual(snoop["payload"], TestDatasetRecords.stringify(deletes, False)) + @staticmethod def records_to_deletes(records): return [ From aa6f76e2d567de0f41afffbaadac82a3b4a5dd18 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Wed, 24 Jul 2019 10:53:06 -0400 Subject: [PATCH 090/632] refactor dataset profile and status --- docs/developer-interface.rst | 4 ++-- .../models/{dataset_profile.py => dataset/profile.py} | 0 tamr_unify_client/models/dataset/resource.py | 10 +++++----- .../models/{dataset_status.py => dataset/status.py} | 0 tests/unit/test_strings.py | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) rename tamr_unify_client/models/{dataset_profile.py => dataset/profile.py} (100%) rename tamr_unify_client/models/{dataset_status.py => dataset/status.py} (100%) diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index 6f8104b7..aaaf1424 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -34,13 +34,13 @@ Dataset Dataset Profile --------------- -.. autoclass:: tamr_unify_client.models.dataset_profile.DatasetProfile +.. autoclass:: tamr_unify_client.models.dataset.profile.DatasetProfile :members: Dataset Status -------------- -.. autoclass:: tamr_unify_client.models.dataset_status.DatasetStatus +.. autoclass:: tamr_unify_client.models.dataset.status.DatasetStatus :members: Dataset Usage diff --git a/tamr_unify_client/models/dataset_profile.py b/tamr_unify_client/models/dataset/profile.py similarity index 100% rename from tamr_unify_client/models/dataset_profile.py rename to tamr_unify_client/models/dataset/profile.py diff --git a/tamr_unify_client/models/dataset/resource.py b/tamr_unify_client/models/dataset/resource.py index b5d27328..1fc76c85 100644 --- a/tamr_unify_client/models/dataset/resource.py +++ b/tamr_unify_client/models/dataset/resource.py @@ -2,9 +2,9 @@ from tamr_unify_client.models.attribute.collection import AttributeCollection from tamr_unify_client.models.base_resource import BaseResource +from tamr_unify_client.models.dataset.profile import DatasetProfile +from tamr_unify_client.models.dataset.status import DatasetStatus from tamr_unify_client.models.dataset.usage import DatasetUsage -from tamr_unify_client.models.dataset_profile import DatasetProfile -from tamr_unify_client.models.dataset_status import DatasetStatus from tamr_unify_client.models.operation import Operation @@ -141,7 +141,7 @@ def profile(self): :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.models.operation.Operation` . :return: Dataset Profile information. - :rtype: :class:`~tamr_unify_client.models.dataset_status.DatasetProfile` + :rtype: :class:`~tamr_unify_client.models.dataset.profile.DatasetProfile` """ profile_json = self.client.get(self.api_path + "/profile").successful().json() return DatasetProfile.from_json( @@ -174,11 +174,11 @@ def records(self): for line in response.iter_lines(): yield json.loads(line) - def status(self) -> DatasetStatus: + def status(self): """Retrieve this dataset's streamability status. :return: Dataset streamability status. - :rtype: :class:`~tamr_unify_client.models.dataset_status.DatasetStatus` + :rtype: :class:`~tamr_unify_client.models.dataset.status.DatasetStatus` """ status_json = self.client.get(self.api_path + "/status").successful().json() return DatasetStatus.from_json( diff --git a/tamr_unify_client/models/dataset_status.py b/tamr_unify_client/models/dataset/status.py similarity index 100% rename from tamr_unify_client/models/dataset_status.py rename to tamr_unify_client/models/dataset/status.py diff --git a/tests/unit/test_strings.py b/tests/unit/test_strings.py index d2951572..e87b7252 100644 --- a/tests/unit/test_strings.py +++ b/tests/unit/test_strings.py @@ -1,6 +1,6 @@ from tamr_unify_client import Client from tamr_unify_client.auth import TokenAuth, UsernamePasswordAuth -from tamr_unify_client.models.dataset_status import DatasetStatus +from tamr_unify_client.models.dataset.status import DatasetStatus def test_client_repr(): @@ -55,7 +55,7 @@ def test_dataset_status_repr(): "isStreamable": True, } status = DatasetStatus.from_json(client, data) - full_clz_name = "tamr_unify_client.models.dataset_status.DatasetStatus" + full_clz_name = "tamr_unify_client.models.dataset.status.DatasetStatus" rstr = f"{status!r}" From bca89be7797cf835f3d4a57e5d19271231cdbcac Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 25 Jul 2019 09:25:52 -0400 Subject: [PATCH 091/632] refactor operation to top level --- docs/developer-interface.rst | 2 +- tamr_unify_client/models/dataset/profile.py | 6 +++--- tamr_unify_client/models/dataset/resource.py | 12 ++++++------ .../models/machine_learning_model.py | 14 +++++++++----- .../models/project/estimated_pair_counts.py | 6 +++--- tamr_unify_client/models/project/mastering.py | 1 - tamr_unify_client/{models => }/operation.py | 16 ++++++++-------- tests/unit/test_pair_counts.py | 2 +- 8 files changed, 31 insertions(+), 28 deletions(-) rename tamr_unify_client/{models => }/operation.py (88%) diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index aaaf1424..d9c5f9d2 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -102,7 +102,7 @@ Machine Learning Models Operations ---------- -.. autoclass:: tamr_unify_client.models.operation.Operation +.. autoclass:: tamr_unify_client.operation.Operation :members: diff --git a/tamr_unify_client/models/dataset/profile.py b/tamr_unify_client/models/dataset/profile.py index 738f38d6..4bd94a5b 100644 --- a/tamr_unify_client/models/dataset/profile.py +++ b/tamr_unify_client/models/dataset/profile.py @@ -1,5 +1,5 @@ from tamr_unify_client.models.base_resource import BaseResource -from tamr_unify_client.models.operation import Operation +from tamr_unify_client.operation import Operation class DatasetProfile(BaseResource): @@ -72,8 +72,8 @@ def refresh(self, **options): :func:`~tamr_unify_client.models.dataset.resource.Dataset.profile` to retrieve the updated profile. - :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.models.operation.Operation` . - See :func:`~tamr_unify_client.models.operation.Operation.apply_options` . + :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.operation.Operation` . + See :func:`~tamr_unify_client.operation.Operation.apply_options` . """ op_json = self.client.post(self.api_path + ":refresh").successful().json() op = Operation.from_json(self.client, op_json) diff --git a/tamr_unify_client/models/dataset/resource.py b/tamr_unify_client/models/dataset/resource.py index 1fc76c85..cd142986 100644 --- a/tamr_unify_client/models/dataset/resource.py +++ b/tamr_unify_client/models/dataset/resource.py @@ -5,7 +5,7 @@ from tamr_unify_client.models.dataset.profile import DatasetProfile from tamr_unify_client.models.dataset.status import DatasetStatus from tamr_unify_client.models.dataset.usage import DatasetUsage -from tamr_unify_client.models.operation import Operation +from tamr_unify_client.operation import Operation class Dataset(BaseResource): @@ -125,8 +125,8 @@ def delete_records_by_id(self, record_ids): def refresh(self, **options): """Brings dataset up-to-date if needed, taking whatever actions are required. - :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.models.operation.Operation` . - See :func:`~tamr_unify_client.models.operation.Operation.apply_options` . + :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.operation.Operation` . + See :func:`~tamr_unify_client.operation.Operation.apply_options` . """ op_json = self.client.post(self.api_path + ":refresh").successful().json() op = Operation.from_json(self.client, op_json) @@ -139,7 +139,7 @@ def profile(self): If the returned profile information is out-of-date, you can call refresh() on the returned object to bring it up-to-date. - :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.models.operation.Operation` . + :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.operation.Operation` . :return: Dataset Profile information. :rtype: :class:`~tamr_unify_client.models.dataset.profile.DatasetProfile` """ @@ -154,8 +154,8 @@ def create_profile(self, **options): If a profile already exists, the existing profile will be brought up to date. - :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.models.operation.Operation` . - See :func:`~tamr_unify_client.models.operation.Operation.apply_options` . + :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.operation.Operation` . + See :func:`~tamr_unify_client.operation.Operation.apply_options` . :return: the operation to create the profile. """ op_json = ( diff --git a/tamr_unify_client/models/machine_learning_model.py b/tamr_unify_client/models/machine_learning_model.py index 4570d71a..96e8d11f 100644 --- a/tamr_unify_client/models/machine_learning_model.py +++ b/tamr_unify_client/models/machine_learning_model.py @@ -1,5 +1,5 @@ from tamr_unify_client.models.base_resource import BaseResource -from tamr_unify_client.models.operation import Operation +from tamr_unify_client.operation import Operation class MachineLearningModel(BaseResource): @@ -12,8 +12,10 @@ def from_json(cls, client, resource_json, api_path=None): def train(self, **options): """Learn from verified labels. - :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.models.operation.Operation` . - See :func:`~tamr_unify_client.models.operation.Operation.apply_options` . + :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.operation.Operation` . + See :func:`~tamr_unify_client.operation.Operation.apply_options` . + :returns: The resultant operation. + :rtype: :class:`~tamr_unify_client.operation.Operation` """ op_json = self.client.post(self.api_path + ":refresh").successful().json() op = Operation.from_json(self.client, op_json) @@ -22,8 +24,10 @@ def train(self, **options): def predict(self, **options): """Suggest labels for unverified records. - :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.models.operation.Operation` . - See :func:`~tamr_unify_client.models.operation.Operation.apply_options` . + :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.operation.Operation` . + See :func:`~tamr_unify_client.operation.Operation.apply_options` . + :returns: The resultant operation. + :rtype: :class:`~tamr_unify_client.operation.Operation` """ dependent_dataset = "/".join(self.api_path.split("/")[:-1]) op_json = self.client.post(dependent_dataset + ":refresh").successful().json() diff --git a/tamr_unify_client/models/project/estimated_pair_counts.py b/tamr_unify_client/models/project/estimated_pair_counts.py index d5d82326..030ba875 100644 --- a/tamr_unify_client/models/project/estimated_pair_counts.py +++ b/tamr_unify_client/models/project/estimated_pair_counts.py @@ -1,5 +1,5 @@ from tamr_unify_client.models.base_resource import BaseResource -from tamr_unify_client.models.operation import Operation +from tamr_unify_client.operation import Operation class EstimatedPairCounts(BaseResource): @@ -58,8 +58,8 @@ def refresh(self, **options): :func:`~tamr_unify_client.models.project.mastering.MasteringProject.estimate_pairs` to retrieve the updated estimate. - :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.models.operation.Operation` . - See :func:`~tamr_unify_client.models.operation.Operation.apply_options` . + :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.operation.Operation` . + See :func:`~tamr_unify_client.operation.Operation.apply_options` . """ op_json = self.client.post(self.api_path + ":refresh").successful().json() op = Operation.from_json(self.client, op_json) diff --git a/tamr_unify_client/models/project/mastering.py b/tamr_unify_client/models/project/mastering.py index 869edacf..5215a44a 100644 --- a/tamr_unify_client/models/project/mastering.py +++ b/tamr_unify_client/models/project/mastering.py @@ -1,7 +1,6 @@ from tamr_unify_client.models.binning_model import BinningModel from tamr_unify_client.models.dataset.resource import Dataset from tamr_unify_client.models.machine_learning_model import MachineLearningModel -from tamr_unify_client.models.operation import Operation from tamr_unify_client.models.project.cluster_configuration import ( PublishedClustersConfiguration, ) diff --git a/tamr_unify_client/models/operation.py b/tamr_unify_client/operation.py similarity index 88% rename from tamr_unify_client/models/operation.py rename to tamr_unify_client/operation.py index ec4be801..ca2eb896 100644 --- a/tamr_unify_client/models/operation.py +++ b/tamr_unify_client/operation.py @@ -31,15 +31,15 @@ def apply_options(self, asynchronous=False, **options): asynchronous mode: Immediately return the ``'PENDING'`` operation. It is up to the user to coordinate this operation with their code via - :func:`~tamr_unify_client.models.operation.Operation.wait` and/or - :func:`~tamr_unify_client.models.operation.Operation.poll` . + :func:`~tamr_unify_client.operation.Operation.wait` and/or + :func:`~tamr_unify_client.operation.Operation.poll` . :param asynchronous: Whether or not to run in asynchronous mode. Default: ``False``. :type asynchronous: bool :param ``**options``: When running in synchronous mode, these options are - passed to the underlying :func:`~tamr_unify_client.models.operation.Operation.wait` call. + passed to the underlying :func:`~tamr_unify_client.operation.Operation.wait` call. :return: Operation with options applied. - :rtype: :class:`~tamr_unify_client.models.operation.Operation` + :rtype: :class:`~tamr_unify_client.operation.Operation` """ if asynchronous: return self @@ -84,11 +84,11 @@ def state(self): def poll(self): """Poll this operation for server-side updates. - Does not update the calling :class:`~tamr_unify_client.models.Operation` object. - Instead, returns a new :class:`~tamr_unify_client.models.Operation`. + Does not update the calling :class:`~tamr_unify_client.operation.Operation` object. + Instead, returns a new :class:`~tamr_unify_client.operation.Operation`. :return: Updated representation of this operation. - :rtype: :class:`~tamr_unify_client.models.Operation` + :rtype: :class:`~tamr_unify_client.operation.Operation` """ op_json = self.client.get(self.api_path).successful().json() return Operation.from_json(self.client, op_json) @@ -100,7 +100,7 @@ def wait(self, poll_interval_seconds=3, timeout_seconds=None): :param int timeout_seconds: Time (in seconds) to wait for operation to resolve. :raises TimeoutError: If operation takes longer than `timeout_seconds` to resolve. :return: Resolved operation. - :rtype: :class:`~tamr_unify_client.models.Operation` + :rtype: :class:`~tamr_unify_client.operation.Operation` """ started = now() op = self diff --git a/tests/unit/test_pair_counts.py b/tests/unit/test_pair_counts.py index bc6dbfa9..d4e7ba02 100644 --- a/tests/unit/test_pair_counts.py +++ b/tests/unit/test_pair_counts.py @@ -4,9 +4,9 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth -from tamr_unify_client.models.operation import Operation from tamr_unify_client.models.project.estimated_pair_counts import EstimatedPairCounts from tamr_unify_client.models.project.mastering import MasteringProject +from tamr_unify_client.operation import Operation class TestPairCounts(TestCase): From d548696f78284877089a1d70eb54fe7268dbd1aa Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 25 Jul 2019 09:28:57 -0400 Subject: [PATCH 092/632] refactor base resource and collection --- tamr_unify_client/{models => }/base_collection.py | 2 +- tamr_unify_client/{models => }/base_resource.py | 0 tamr_unify_client/models/attribute/collection.py | 2 +- tamr_unify_client/models/attribute/resource.py | 2 +- tamr_unify_client/models/attribute_configuration/collection.py | 2 +- tamr_unify_client/models/attribute_configuration/resource.py | 2 +- tamr_unify_client/models/binning_model.py | 2 +- tamr_unify_client/models/category/collection.py | 2 +- tamr_unify_client/models/category/resource.py | 2 +- tamr_unify_client/models/dataset/collection.py | 2 +- tamr_unify_client/models/dataset/profile.py | 2 +- tamr_unify_client/models/dataset/resource.py | 2 +- tamr_unify_client/models/dataset/status.py | 2 +- tamr_unify_client/models/dataset/usage.py | 2 +- tamr_unify_client/models/machine_learning_model.py | 2 +- tamr_unify_client/models/project/cluster_configuration.py | 2 +- tamr_unify_client/models/project/collection.py | 2 +- tamr_unify_client/models/project/estimated_pair_counts.py | 2 +- tamr_unify_client/models/project/resource.py | 2 +- tamr_unify_client/models/taxonomy/resource.py | 2 +- tamr_unify_client/operation.py | 2 +- 21 files changed, 20 insertions(+), 20 deletions(-) rename tamr_unify_client/{models => }/base_collection.py (98%) rename tamr_unify_client/{models => }/base_resource.py (100%) diff --git a/tamr_unify_client/models/base_collection.py b/tamr_unify_client/base_collection.py similarity index 98% rename from tamr_unify_client/models/base_collection.py rename to tamr_unify_client/base_collection.py index 94585ff0..c88cf139 100644 --- a/tamr_unify_client/models/base_collection.py +++ b/tamr_unify_client/base_collection.py @@ -27,7 +27,7 @@ def by_resource_id(self, canonical_path, resource_id): :param resource_id: The resource ID. E.g. "1" :type resource_id: str :returns: The specified item. - :rtype: The ``resource_class`` for this collection. See :func:`~tamr_unify_client.models.base_collection.BaseCollection.by_relative_id`. + :rtype: The ``resource_class`` for this collection. See :func:`~tamr_unify_client.base_collection.BaseCollection.by_relative_id`. """ relative_id = canonical_path + "/" + resource_id return self.by_relative_id(relative_id) diff --git a/tamr_unify_client/models/base_resource.py b/tamr_unify_client/base_resource.py similarity index 100% rename from tamr_unify_client/models/base_resource.py rename to tamr_unify_client/base_resource.py diff --git a/tamr_unify_client/models/attribute/collection.py b/tamr_unify_client/models/attribute/collection.py index 7e1ce8bf..1aa5f9bc 100644 --- a/tamr_unify_client/models/attribute/collection.py +++ b/tamr_unify_client/models/attribute/collection.py @@ -1,5 +1,5 @@ +from tamr_unify_client.base_collection import BaseCollection from tamr_unify_client.models.attribute.resource import Attribute -from tamr_unify_client.models.base_collection import BaseCollection class AttributeCollection(BaseCollection): diff --git a/tamr_unify_client/models/attribute/resource.py b/tamr_unify_client/models/attribute/resource.py index d1888324..a7dd5087 100644 --- a/tamr_unify_client/models/attribute/resource.py +++ b/tamr_unify_client/models/attribute/resource.py @@ -1,5 +1,5 @@ +from tamr_unify_client.base_resource import BaseResource from tamr_unify_client.models.attribute.type import AttributeType -from tamr_unify_client.models.base_resource import BaseResource class Attribute(BaseResource): diff --git a/tamr_unify_client/models/attribute_configuration/collection.py b/tamr_unify_client/models/attribute_configuration/collection.py index 6bcf2510..b5ecb1be 100644 --- a/tamr_unify_client/models/attribute_configuration/collection.py +++ b/tamr_unify_client/models/attribute_configuration/collection.py @@ -1,7 +1,7 @@ +from tamr_unify_client.base_collection import BaseCollection from tamr_unify_client.models.attribute_configuration.resource import ( AttributeConfiguration, ) -from tamr_unify_client.models.base_collection import BaseCollection class AttributeConfigurationCollection(BaseCollection): diff --git a/tamr_unify_client/models/attribute_configuration/resource.py b/tamr_unify_client/models/attribute_configuration/resource.py index 982d7eeb..8aa774b5 100644 --- a/tamr_unify_client/models/attribute_configuration/resource.py +++ b/tamr_unify_client/models/attribute_configuration/resource.py @@ -1,4 +1,4 @@ -from tamr_unify_client.models.base_resource import BaseResource +from tamr_unify_client.base_resource import BaseResource class AttributeConfiguration(BaseResource): diff --git a/tamr_unify_client/models/binning_model.py b/tamr_unify_client/models/binning_model.py index 8d367296..bcb64540 100644 --- a/tamr_unify_client/models/binning_model.py +++ b/tamr_unify_client/models/binning_model.py @@ -1,6 +1,6 @@ import json -from tamr_unify_client.models.base_resource import BaseResource +from tamr_unify_client.base_resource import BaseResource class BinningModel(BaseResource): diff --git a/tamr_unify_client/models/category/collection.py b/tamr_unify_client/models/category/collection.py index 5185efa8..e78d154b 100644 --- a/tamr_unify_client/models/category/collection.py +++ b/tamr_unify_client/models/category/collection.py @@ -1,6 +1,6 @@ import json -from tamr_unify_client.models.base_collection import BaseCollection +from tamr_unify_client.base_collection import BaseCollection from tamr_unify_client.models.category.resource import Category diff --git a/tamr_unify_client/models/category/resource.py b/tamr_unify_client/models/category/resource.py index 1989430d..874113ba 100644 --- a/tamr_unify_client/models/category/resource.py +++ b/tamr_unify_client/models/category/resource.py @@ -1,4 +1,4 @@ -from tamr_unify_client.models.base_resource import BaseResource +from tamr_unify_client.base_resource import BaseResource class Category(BaseResource): diff --git a/tamr_unify_client/models/dataset/collection.py b/tamr_unify_client/models/dataset/collection.py index f4aab1a0..e74ba6d0 100644 --- a/tamr_unify_client/models/dataset/collection.py +++ b/tamr_unify_client/models/dataset/collection.py @@ -1,4 +1,4 @@ -from tamr_unify_client.models.base_collection import BaseCollection +from tamr_unify_client.base_collection import BaseCollection from tamr_unify_client.models.dataset.resource import Dataset diff --git a/tamr_unify_client/models/dataset/profile.py b/tamr_unify_client/models/dataset/profile.py index 4bd94a5b..8e908de1 100644 --- a/tamr_unify_client/models/dataset/profile.py +++ b/tamr_unify_client/models/dataset/profile.py @@ -1,4 +1,4 @@ -from tamr_unify_client.models.base_resource import BaseResource +from tamr_unify_client.base_resource import BaseResource from tamr_unify_client.operation import Operation diff --git a/tamr_unify_client/models/dataset/resource.py b/tamr_unify_client/models/dataset/resource.py index cd142986..5face170 100644 --- a/tamr_unify_client/models/dataset/resource.py +++ b/tamr_unify_client/models/dataset/resource.py @@ -1,7 +1,7 @@ import simplejson as json +from tamr_unify_client.base_resource import BaseResource from tamr_unify_client.models.attribute.collection import AttributeCollection -from tamr_unify_client.models.base_resource import BaseResource from tamr_unify_client.models.dataset.profile import DatasetProfile from tamr_unify_client.models.dataset.status import DatasetStatus from tamr_unify_client.models.dataset.usage import DatasetUsage diff --git a/tamr_unify_client/models/dataset/status.py b/tamr_unify_client/models/dataset/status.py index 0bfd850f..46741a62 100644 --- a/tamr_unify_client/models/dataset/status.py +++ b/tamr_unify_client/models/dataset/status.py @@ -1,4 +1,4 @@ -from tamr_unify_client.models.base_resource import BaseResource +from tamr_unify_client.base_resource import BaseResource class DatasetStatus(BaseResource): diff --git a/tamr_unify_client/models/dataset/usage.py b/tamr_unify_client/models/dataset/usage.py index 8a111db0..a92c41ff 100644 --- a/tamr_unify_client/models/dataset/usage.py +++ b/tamr_unify_client/models/dataset/usage.py @@ -1,4 +1,4 @@ -from tamr_unify_client.models.base_resource import BaseResource +from tamr_unify_client.base_resource import BaseResource from tamr_unify_client.models.dataset.use import DatasetUse diff --git a/tamr_unify_client/models/machine_learning_model.py b/tamr_unify_client/models/machine_learning_model.py index 96e8d11f..f5df8966 100644 --- a/tamr_unify_client/models/machine_learning_model.py +++ b/tamr_unify_client/models/machine_learning_model.py @@ -1,4 +1,4 @@ -from tamr_unify_client.models.base_resource import BaseResource +from tamr_unify_client.base_resource import BaseResource from tamr_unify_client.operation import Operation diff --git a/tamr_unify_client/models/project/cluster_configuration.py b/tamr_unify_client/models/project/cluster_configuration.py index af0278c3..8af63749 100644 --- a/tamr_unify_client/models/project/cluster_configuration.py +++ b/tamr_unify_client/models/project/cluster_configuration.py @@ -1,4 +1,4 @@ -from tamr_unify_client.models.base_resource import BaseResource +from tamr_unify_client.base_resource import BaseResource class PublishedClustersConfiguration(BaseResource): diff --git a/tamr_unify_client/models/project/collection.py b/tamr_unify_client/models/project/collection.py index 1cf4c407..0221a318 100644 --- a/tamr_unify_client/models/project/collection.py +++ b/tamr_unify_client/models/project/collection.py @@ -1,4 +1,4 @@ -from tamr_unify_client.models.base_collection import BaseCollection +from tamr_unify_client.base_collection import BaseCollection from tamr_unify_client.models.project.resource import Project diff --git a/tamr_unify_client/models/project/estimated_pair_counts.py b/tamr_unify_client/models/project/estimated_pair_counts.py index 030ba875..4b32a07b 100644 --- a/tamr_unify_client/models/project/estimated_pair_counts.py +++ b/tamr_unify_client/models/project/estimated_pair_counts.py @@ -1,4 +1,4 @@ -from tamr_unify_client.models.base_resource import BaseResource +from tamr_unify_client.base_resource import BaseResource from tamr_unify_client.operation import Operation diff --git a/tamr_unify_client/models/project/resource.py b/tamr_unify_client/models/project/resource.py index 290d3099..4f571559 100644 --- a/tamr_unify_client/models/project/resource.py +++ b/tamr_unify_client/models/project/resource.py @@ -1,7 +1,7 @@ +from tamr_unify_client.base_resource import BaseResource from tamr_unify_client.models.attribute_configuration.collection import ( AttributeConfigurationCollection, ) -from tamr_unify_client.models.base_resource import BaseResource from tamr_unify_client.models.dataset.collection import DatasetCollection from tamr_unify_client.models.dataset.resource import Dataset diff --git a/tamr_unify_client/models/taxonomy/resource.py b/tamr_unify_client/models/taxonomy/resource.py index 5ec87527..bb2c96f4 100644 --- a/tamr_unify_client/models/taxonomy/resource.py +++ b/tamr_unify_client/models/taxonomy/resource.py @@ -1,4 +1,4 @@ -from tamr_unify_client.models.base_resource import BaseResource +from tamr_unify_client.base_resource import BaseResource from tamr_unify_client.models.category.collection import CategoryCollection diff --git a/tamr_unify_client/operation.py b/tamr_unify_client/operation.py index ca2eb896..509e5fcd 100644 --- a/tamr_unify_client/operation.py +++ b/tamr_unify_client/operation.py @@ -1,6 +1,6 @@ from time import sleep, time as now -from tamr_unify_client.models.base_resource import BaseResource +from tamr_unify_client.base_resource import BaseResource class Operation(BaseResource): From 20f059a833ccbc1078c922e45ced1e86c919f5f0 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 25 Jul 2019 09:35:11 -0400 Subject: [PATCH 093/632] refactor attribute --- docs/developer-interface.rst | 8 ++++---- .../{models => }/attribute/__init__.py | 0 .../{models => }/attribute/collection.py | 16 ++++++++-------- .../{models => }/attribute/resource.py | 4 ++-- .../{models => }/attribute/subattribute.py | 7 ++++--- tamr_unify_client/{models => }/attribute/type.py | 11 ++++++----- tamr_unify_client/models/dataset/resource.py | 4 ++-- tamr_unify_client/models/project/resource.py | 4 ++-- tests/unit/test_attribute.py | 2 +- tests/unit/test_dataset_attributes.py | 3 --- 10 files changed, 29 insertions(+), 30 deletions(-) rename tamr_unify_client/{models => }/attribute/__init__.py (100%) rename tamr_unify_client/{models => }/attribute/collection.py (85%) rename tamr_unify_client/{models => }/attribute/resource.py (92%) rename tamr_unify_client/{models => }/attribute/subattribute.py (83%) rename tamr_unify_client/{models => }/attribute/type.py (65%) diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index d9c5f9d2..78dab5b3 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -63,19 +63,19 @@ Datasets Attribute --------- -.. autoclass:: tamr_unify_client.models.attribute.resource.Attribute +.. autoclass:: tamr_unify_client.attribute.resource.Attribute -.. autoclass:: tamr_unify_client.models.attribute.subattribute.SubAttribute +.. autoclass:: tamr_unify_client.attribute.subattribute.SubAttribute Attribute Type -------------- -.. autoclass:: tamr_unify_client.models.attribute.type.AttributeType +.. autoclass:: tamr_unify_client.attribute.type.AttributeType Attributes ---------- -.. autoclass:: tamr_unify_client.models.attribute.collection.AttributeCollection +.. autoclass:: tamr_unify_client.attribute.collection.AttributeCollection Attribute Configuration ---------- diff --git a/tamr_unify_client/models/attribute/__init__.py b/tamr_unify_client/attribute/__init__.py similarity index 100% rename from tamr_unify_client/models/attribute/__init__.py rename to tamr_unify_client/attribute/__init__.py diff --git a/tamr_unify_client/models/attribute/collection.py b/tamr_unify_client/attribute/collection.py similarity index 85% rename from tamr_unify_client/models/attribute/collection.py rename to tamr_unify_client/attribute/collection.py index 1aa5f9bc..b7d0d7f2 100644 --- a/tamr_unify_client/models/attribute/collection.py +++ b/tamr_unify_client/attribute/collection.py @@ -1,9 +1,9 @@ +from tamr_unify_client.attribute.resource import Attribute from tamr_unify_client.base_collection import BaseCollection -from tamr_unify_client.models.attribute.resource import Attribute class AttributeCollection(BaseCollection): - """Collection of :class:`~tamr_unify_client.models.attribute.resource.Attribute` s. + """Collection of :class:`~tamr_unify_client.attribute.resource.Attribute` s. :param client: Client for API call delegation. :type client: :class:`~tamr_unify_client.Client` @@ -21,7 +21,7 @@ def by_resource_id(self, resource_id): :param resource_id: The resource ID. E.g. ``"AttributeName"`` :type resource_id: str :returns: The specified attribute. - :rtype: :class:`~tamr_unify_client.models.attribute.resource.Attribute` + :rtype: :class:`~tamr_unify_client.attribute.resource.Attribute` """ return self.by_name(resource_id) @@ -31,7 +31,7 @@ def by_relative_id(self, relative_id): :param relative_id: The resource ID. E.g. ``"datasets/1/attributes/AttributeName"`` :type relative_id: str :returns: The specified attribute. - :rtype: :class:`~tamr_unify_client.models.attribute.resource.Attribute` + :rtype: :class:`~tamr_unify_client.attribute.resource.Attribute` """ resource_id = relative_id.split("/")[-1] return self.by_resource_id(resource_id) @@ -45,7 +45,7 @@ def by_external_id(self, external_id): :param external_id: The external ID. :type external_id: str :returns: The specified attribute, if found. - :rtype: :class:`~tamr_unify_client.models.attribute.resource.Attribute` + :rtype: :class:`~tamr_unify_client.attribute.resource.Attribute` :raises KeyError: If no attribute with the specified external_id is found :raises LookupError: If multiple attributes with the specified external_id are found """ @@ -56,7 +56,7 @@ def stream(self): over this collection. :returns: Stream of attributes. - :rtype: Python generator yielding :class:`~tamr_unify_client.models.attribute.resource.Attribute` + :rtype: Python generator yielding :class:`~tamr_unify_client.attribute.resource.Attribute` Usage: >>> for attribute in collection.stream(): # explicit @@ -75,7 +75,7 @@ def by_name(self, attribute_name): :param attribute_name: Name of the desired attribute. :type attribute_name: str :return: Attribute with matching name in this collection. - :rtype: :class:`~tamr_unify_client.models.attribute.resource.Attribute` + :rtype: :class:`~tamr_unify_client.attribute.resource.Attribute` :raises KeyError: If no attribute with specified name was found. """ for attribute in self: @@ -90,7 +90,7 @@ def create(self, creation_spec): :param creation_spec: Attribute creation specification should be formatted as specified in the `Public Docs for adding an Attribute `_. :type creation_spec: dict[str, str] :returns: The created Attribute - :rtype: :class:`~tamr_unify_client.models.attribute.resource.Attribute` + :rtype: :class:`~tamr_unify_client.attribute.resource.Attribute` """ data = self.client.post(self.api_path, json=creation_spec).successful().json() alias = self.api_path + "/" + creation_spec["name"] diff --git a/tamr_unify_client/models/attribute/resource.py b/tamr_unify_client/attribute/resource.py similarity index 92% rename from tamr_unify_client/models/attribute/resource.py rename to tamr_unify_client/attribute/resource.py index a7dd5087..397b7872 100644 --- a/tamr_unify_client/models/attribute/resource.py +++ b/tamr_unify_client/attribute/resource.py @@ -1,5 +1,5 @@ +from tamr_unify_client.attribute.type import AttributeType from tamr_unify_client.base_resource import BaseResource -from tamr_unify_client.models.attribute.type import AttributeType class Attribute(BaseResource): @@ -43,7 +43,7 @@ def description(self): @property def type(self): - """:type: :class:`~tamr_unify_client.models.attribute.type.AttributeType`""" + """:type: :class:`~tamr_unify_client.attribute.type.AttributeType`""" type_json = self._data.get("type") return AttributeType(type_json) diff --git a/tamr_unify_client/models/attribute/subattribute.py b/tamr_unify_client/attribute/subattribute.py similarity index 83% rename from tamr_unify_client/models/attribute/subattribute.py rename to tamr_unify_client/attribute/subattribute.py index cc807647..0f0665b5 100644 --- a/tamr_unify_client/models/attribute/subattribute.py +++ b/tamr_unify_client/attribute/subattribute.py @@ -1,10 +1,11 @@ class SubAttribute: """ An attribute which is itself a property of another attribute. + See https://docs.tamr.com/reference#attribute-types :param data: JSON data representing this attribute - :type: :py:class:`dict` + :type data: :py:class:`dict` """ def __init__(self, data): @@ -22,9 +23,9 @@ def description(self): @property def type(self): - """:type: :class:`~tamr_unify_client.models.attribute.type.AttributeType`""" + """:type: :class:`~tamr_unify_client.attribute.type.AttributeType`""" # import locally to avoid circular dependency - from tamr_unify_client.models.attribute.type import AttributeType + from tamr_unify_client.attribute.type import AttributeType type_json = self._data.get("type") return AttributeType(type_json) diff --git a/tamr_unify_client/models/attribute/type.py b/tamr_unify_client/attribute/type.py similarity index 65% rename from tamr_unify_client/models/attribute/type.py rename to tamr_unify_client/attribute/type.py index abcf7eef..8e16602a 100644 --- a/tamr_unify_client/models/attribute/type.py +++ b/tamr_unify_client/attribute/type.py @@ -1,13 +1,14 @@ -from tamr_unify_client.models.attribute.subattribute import SubAttribute +from tamr_unify_client.attribute.subattribute import SubAttribute class AttributeType: """ - The type of an :class:`~tamr_unify_client.models.attribute.resource.Attribute` or :class:`~tamr_unify_client.models.attribute.subattribute.SubAttribute`. + The type of an :class:`~tamr_unify_client.attribute.resource.Attribute` or :class:`~tamr_unify_client.attribute.subattribute.SubAttribute`. + See https://docs.tamr.com/reference#attribute-types :param data: JSON data representing this type - :type: :py:class:`dict` + :type data: :py:class:`dict` """ def __init__(self, data): @@ -20,7 +21,7 @@ def base_type(self): @property def inner_type(self): - """:type: :class:`~tamr_unify_client.models.attribute.type.AttributeType`""" + """:type: :class:`~tamr_unify_client.attribute.type.AttributeType`""" if "innerType" in self._data: return AttributeType(self._data.get("innerType")) else: @@ -28,7 +29,7 @@ def inner_type(self): @property def attributes(self): - """:type: list[:class:`~tamr_unify_client.models.attribute.subattribute.SubAttribute`]""" + """:type: list[:class:`~tamr_unify_client.attribute.subattribute.SubAttribute`]""" collection_json = self._data.get("attributes") return [SubAttribute(attr) for attr in collection_json] diff --git a/tamr_unify_client/models/dataset/resource.py b/tamr_unify_client/models/dataset/resource.py index 5face170..fdb1c429 100644 --- a/tamr_unify_client/models/dataset/resource.py +++ b/tamr_unify_client/models/dataset/resource.py @@ -1,7 +1,7 @@ import simplejson as json +from tamr_unify_client.attribute.collection import AttributeCollection from tamr_unify_client.base_resource import BaseResource -from tamr_unify_client.models.attribute.collection import AttributeCollection from tamr_unify_client.models.dataset.profile import DatasetProfile from tamr_unify_client.models.dataset.status import DatasetStatus from tamr_unify_client.models.dataset.usage import DatasetUsage @@ -50,7 +50,7 @@ def attributes(self): """Attributes of this dataset. :return: Attributes of this dataset. - :rtype: :class:`~tamr_unify_client.models.attribute.collection.AttributeCollection` + :rtype: :class:`~tamr_unify_client.attribute.collection.AttributeCollection` """ alias = self.api_path + "/attributes" return AttributeCollection(self.client, alias) diff --git a/tamr_unify_client/models/project/resource.py b/tamr_unify_client/models/project/resource.py index 4f571559..a24e2428 100644 --- a/tamr_unify_client/models/project/resource.py +++ b/tamr_unify_client/models/project/resource.py @@ -45,9 +45,9 @@ def attributes(self): """Attributes of this project. :return: Attributes of this project. - :rtype: :class:`~tamr_unify_client.models.attribute.collection.AttributeCollection` + :rtype: :class:`~tamr_unify_client.attribute.collection.AttributeCollection` """ - from tamr_unify_client.models.attribute.collection import AttributeCollection + from tamr_unify_client.attribute.collection import AttributeCollection alias = self.api_path + "/attributes" return AttributeCollection(self.client, alias) diff --git a/tests/unit/test_attribute.py b/tests/unit/test_attribute.py index 59883230..44ac05b1 100644 --- a/tests/unit/test_attribute.py +++ b/tests/unit/test_attribute.py @@ -3,8 +3,8 @@ import responses from tamr_unify_client import Client +from tamr_unify_client.attribute.resource import Attribute from tamr_unify_client.auth import UsernamePasswordAuth -from tamr_unify_client.models.attribute.resource import Attribute class TestAttribute(TestCase): diff --git a/tests/unit/test_dataset_attributes.py b/tests/unit/test_dataset_attributes.py index 21fb3edf..f6df2485 100644 --- a/tests/unit/test_dataset_attributes.py +++ b/tests/unit/test_dataset_attributes.py @@ -1,10 +1,7 @@ -import json - import responses from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth -from tamr_unify_client.models.attribute.resource import Attribute auth = UsernamePasswordAuth("username", "password") unify = Client(auth) From 63338bf7ebebd4cb3aaa569ae992ee3f600fdc5d Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 25 Jul 2019 09:45:49 -0400 Subject: [PATCH 094/632] refactor category --- docs/developer-interface.rst | 4 ++-- .../{models => }/category/__init__.py | 0 .../{models => }/category/collection.py | 19 ++++++++++--------- .../{models => }/category/resource.py | 2 +- tamr_unify_client/models/taxonomy/resource.py | 4 ++-- tests/unit/test_category.py | 2 +- tests/unit/test_taxonomy.py | 4 ++-- 7 files changed, 18 insertions(+), 17 deletions(-) rename tamr_unify_client/{models => }/category/__init__.py (100%) rename tamr_unify_client/{models => }/category/collection.py (85%) rename tamr_unify_client/{models => }/category/resource.py (95%) diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index 78dab5b3..01dc9139 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -16,13 +16,13 @@ Client Category -------- -.. autoclass:: tamr_unify_client.models.category.resource.Category +.. autoclass:: tamr_unify_client.category.resource.Category :members: Categories ---------- -.. autoclass:: tamr_unify_client.models.category.collection.CategoryCollection +.. autoclass:: tamr_unify_client.category.collection.CategoryCollection :members: Dataset diff --git a/tamr_unify_client/models/category/__init__.py b/tamr_unify_client/category/__init__.py similarity index 100% rename from tamr_unify_client/models/category/__init__.py rename to tamr_unify_client/category/__init__.py diff --git a/tamr_unify_client/models/category/collection.py b/tamr_unify_client/category/collection.py similarity index 85% rename from tamr_unify_client/models/category/collection.py rename to tamr_unify_client/category/collection.py index e78d154b..7c850417 100644 --- a/tamr_unify_client/models/category/collection.py +++ b/tamr_unify_client/category/collection.py @@ -1,11 +1,11 @@ import json from tamr_unify_client.base_collection import BaseCollection -from tamr_unify_client.models.category.resource import Category +from tamr_unify_client.category.resource import Category class CategoryCollection(BaseCollection): - """Collection of :class:`~tamr_unify_client.models.category.resource.Category` s. + """Collection of :class:`~tamr_unify_client.category.resource.Category` s. :param client: Client for API call delegation. :type client: :class:`~tamr_unify_client.Client` @@ -23,7 +23,7 @@ def by_resource_id(self, resource_id): :param resource_id: The resource ID. E.g. ``"1"`` :type resource_id: str :returns: The specified category. - :rtype: :class:`~tamr_unify_client.models.category.resource.Category` + :rtype: :class:`~tamr_unify_client.category.resource.Category` """ return super().by_resource_id(self.api_path, resource_id) @@ -33,7 +33,7 @@ def by_relative_id(self, relative_id): :param relative_id: The relative ID. E.g. ``"projects/1/categories/1"`` :type relative_id: str :returns: The specified category. - :rtype: :class:`~tamr_unify_client.models.category.resource.Category` + :rtype: :class:`~tamr_unify_client.category.resource.Category` """ return super().by_relative_id(Category, relative_id) @@ -46,7 +46,7 @@ def by_external_id(self, external_id): :param external_id: The external ID. :type external_id: str :returns: The specified category, if found. - :rtype: :class:`~tamr_unify_client.models.category.resource.Category` + :rtype: :class:`~tamr_unify_client.category.resource.Category` :raises KeyError: If no category with the specified external_id is found :raises LookupError: If multiple categories with the specified external_id are found """ @@ -57,7 +57,7 @@ def stream(self): over this collection. :returns: Stream of categories. - :rtype: Python generator yielding :class:`~tamr_unify_client.models.category.resource.Category` + :rtype: Python generator yielding :class:`~tamr_unify_client.category.resource.Category` Usage: >>> for category in collection.stream(): # explicit @@ -72,9 +72,9 @@ def create(self, creation_spec): :param creation_spec: Category creation specification, formatted as specified in the `Public Docs for Creating a Category `_. - :type: dict + :type creation_spec: dict :return: The newly created category. - :rtype: :class:`~tamr_unify_client.models.category.resource.Category` + :rtype: :class:`~tamr_unify_client.category.resource.Category` """ resource_json = ( self.client.post(self.api_path, json=creation_spec).successful().json() @@ -85,8 +85,9 @@ def bulk_create(self, creation_specs): """Creates new categories in bulk. :param creation_specs: A collection of creation specifications, as detailed for create. - :type: iterable[dict] + :type creation_specs: iterable[dict] :returns: JSON response from the server + :rtype: :py:class:`dict` """ body = "\n".join([json.dumps(s) for s in creation_specs]).encode("utf-8") return ( diff --git a/tamr_unify_client/models/category/resource.py b/tamr_unify_client/category/resource.py similarity index 95% rename from tamr_unify_client/models/category/resource.py rename to tamr_unify_client/category/resource.py index 874113ba..7fe02d3d 100644 --- a/tamr_unify_client/models/category/resource.py +++ b/tamr_unify_client/category/resource.py @@ -27,7 +27,7 @@ def parent(self): """Gets the parent Category of this one, or None if it is a tier 1 category :returns: The parent Category or None - :rtype: Category + :rtype: :class:`~tamr_unify_client.category.resource.Category` """ parent = self._data.get("parent") if parent: diff --git a/tamr_unify_client/models/taxonomy/resource.py b/tamr_unify_client/models/taxonomy/resource.py index bb2c96f4..faccb59f 100644 --- a/tamr_unify_client/models/taxonomy/resource.py +++ b/tamr_unify_client/models/taxonomy/resource.py @@ -1,5 +1,5 @@ from tamr_unify_client.base_resource import BaseResource -from tamr_unify_client.models.category.collection import CategoryCollection +from tamr_unify_client.category.collection import CategoryCollection class Taxonomy(BaseResource): @@ -18,7 +18,7 @@ def categories(self): """Retrieves the categories of this taxonomy. :returns: A collection of the taxonomy categories. - :rtype: :class:`~tamr_unify_client.models.category.collection.CategoryCollection` + :rtype: :class:`~tamr_unify_client.category.collection.CategoryCollection` """ alias = self.api_path + "/categories" return CategoryCollection(self.client, alias) diff --git a/tests/unit/test_category.py b/tests/unit/test_category.py index ac2e724b..016b19c4 100644 --- a/tests/unit/test_category.py +++ b/tests/unit/test_category.py @@ -4,7 +4,7 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth -from tamr_unify_client.models.category.resource import Category +from tamr_unify_client.category.resource import Category class TestCategory(TestCase): diff --git a/tests/unit/test_taxonomy.py b/tests/unit/test_taxonomy.py index 4124390a..1217b207 100644 --- a/tests/unit/test_taxonomy.py +++ b/tests/unit/test_taxonomy.py @@ -6,8 +6,8 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth -from tamr_unify_client.models.category.collection import CategoryCollection -from tamr_unify_client.models.category.resource import Category +from tamr_unify_client.category.collection import CategoryCollection +from tamr_unify_client.category.resource import Category from tamr_unify_client.models.taxonomy.resource import Taxonomy From 5f4cb6e0cac34d5f80f2531f2924bc392669172d Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 25 Jul 2019 09:57:39 -0400 Subject: [PATCH 095/632] refactor dataset --- docs/developer-interface.rst | 12 ++++----- tamr_unify_client/client.py | 4 +-- .../{models => }/dataset/__init__.py | 0 .../{models => }/dataset/collection.py | 16 ++++++------ .../{models => }/dataset/profile.py | 4 ++- .../{models => }/dataset/resource.py | 25 +++++++++++-------- .../{models => }/dataset/status.py | 0 .../{models => }/dataset/usage.py | 6 ++--- tamr_unify_client/{models => }/dataset/use.py | 6 ++--- tamr_unify_client/models/project/mastering.py | 24 +++++++++--------- tamr_unify_client/models/project/resource.py | 12 ++++----- tamr_unify_client/operation.py | 2 +- tests/unit/test_dataset_geo.py | 2 +- tests/unit/test_dataset_usage.py | 6 ++--- tests/unit/test_strings.py | 6 ++--- 15 files changed, 65 insertions(+), 60 deletions(-) rename tamr_unify_client/{models => }/dataset/__init__.py (100%) rename tamr_unify_client/{models => }/dataset/collection.py (84%) rename tamr_unify_client/{models => }/dataset/profile.py (94%) rename tamr_unify_client/{models => }/dataset/resource.py (94%) rename tamr_unify_client/{models => }/dataset/status.py (100%) rename tamr_unify_client/{models => }/dataset/usage.py (80%) rename tamr_unify_client/{models => }/dataset/use.py (87%) diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index 01dc9139..43b14619 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -28,36 +28,36 @@ Categories Dataset ------- -.. autoclass:: tamr_unify_client.models.dataset.resource.Dataset +.. autoclass:: tamr_unify_client.dataset.resource.Dataset :members: Dataset Profile --------------- -.. autoclass:: tamr_unify_client.models.dataset.profile.DatasetProfile +.. autoclass:: tamr_unify_client.dataset.profile.DatasetProfile :members: Dataset Status -------------- -.. autoclass:: tamr_unify_client.models.dataset.status.DatasetStatus +.. autoclass:: tamr_unify_client.dataset.status.DatasetStatus :members: Dataset Usage ------------- -.. autoclass:: tamr_unify_client.models.dataset.usage.DatasetUsage +.. autoclass:: tamr_unify_client.dataset.usage.DatasetUsage :members: ---- -.. autoclass:: tamr_unify_client.models.dataset.use.DatasetUse +.. autoclass:: tamr_unify_client.dataset.use.DatasetUse :members: Datasets -------- -.. autoclass:: tamr_unify_client.models.dataset.collection.DatasetCollection +.. autoclass:: tamr_unify_client.dataset.collection.DatasetCollection :members: Attribute diff --git a/tamr_unify_client/client.py b/tamr_unify_client/client.py index 56d90d54..7c5045d9 100644 --- a/tamr_unify_client/client.py +++ b/tamr_unify_client/client.py @@ -3,7 +3,7 @@ import requests from requests import Response -from tamr_unify_client.models.dataset.collection import DatasetCollection +from tamr_unify_client.dataset.collection import DatasetCollection from tamr_unify_client.models.project.collection import ProjectCollection # monkey-patch Response.successful @@ -147,7 +147,7 @@ def datasets(self): """Collection of all datasets on this Unify instance. :return: Collection of all datasets. - :rtype: :class:`~tamr_unify_client.models.DatasetCollection` + :rtype: :class:`~tamr_unify_client.dataset.collection.DatasetCollection` """ return self._datasets diff --git a/tamr_unify_client/models/dataset/__init__.py b/tamr_unify_client/dataset/__init__.py similarity index 100% rename from tamr_unify_client/models/dataset/__init__.py rename to tamr_unify_client/dataset/__init__.py diff --git a/tamr_unify_client/models/dataset/collection.py b/tamr_unify_client/dataset/collection.py similarity index 84% rename from tamr_unify_client/models/dataset/collection.py rename to tamr_unify_client/dataset/collection.py index e74ba6d0..b23f70fc 100644 --- a/tamr_unify_client/models/dataset/collection.py +++ b/tamr_unify_client/dataset/collection.py @@ -1,9 +1,9 @@ from tamr_unify_client.base_collection import BaseCollection -from tamr_unify_client.models.dataset.resource import Dataset +from tamr_unify_client.dataset.resource import Dataset class DatasetCollection(BaseCollection): - """Collection of :class:`~tamr_unify_client.models.dataset.resource.Dataset` s. + """Collection of :class:`~tamr_unify_client.dataset.resource.Dataset` s. :param client: Client for API call delegation. :type client: :class:`~tamr_unify_client.Client` @@ -22,7 +22,7 @@ def by_resource_id(self, resource_id): :param resource_id: The resource ID. E.g. ``"1"`` :type resource_id: str :returns: The specified dataset. - :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` + :rtype: :class:`~tamr_unify_client.dataset.resource.Dataset` """ return super().by_resource_id("datasets", resource_id) @@ -32,7 +32,7 @@ def by_relative_id(self, relative_id): :param relative_id: The resource ID. E.g. ``"datasets/1"`` :type relative_id: str :returns: The specified dataset. - :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` + :rtype: :class:`~tamr_unify_client.dataset.resource.Dataset` """ return super().by_relative_id(Dataset, relative_id) @@ -42,7 +42,7 @@ def by_external_id(self, external_id): :param external_id: The external ID. :type external_id: str :returns: The specified dataset, if found. - :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` + :rtype: :class:`~tamr_unify_client.dataset.resource.Dataset` :raises KeyError: If no dataset with the specified external_id is found :raises LookupError: If multiple datasets with the specified external_id are found """ @@ -53,7 +53,7 @@ def stream(self): over this collection. :returns: Stream of datasets. - :rtype: Python generator yielding :class:`~tamr_unify_client.models.dataset.resource.Dataset` + :rtype: Python generator yielding :class:`~tamr_unify_client.dataset.resource.Dataset` Usage: >>> for dataset in collection.stream(): # explicit @@ -69,7 +69,7 @@ def by_name(self, dataset_name): :param dataset_name: Name of the desired dataset. :type dataset_name: str :return: Dataset with matching name in this collection. - :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` + :rtype: :class:`~tamr_unify_client.dataset.resource.Dataset` :raises KeyError: If no dataset with specified name was found. """ for dataset in self: @@ -84,7 +84,7 @@ def create(self, creation_spec): :param creation_spec: Dataset creation specification should be formatted as specified in the `Public Docs for Creating a Dataset `_. :type creation_spec: dict[str, str] :returns: The created Dataset - :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` + :rtype: :class:`~tamr_unify_client.dataset.resource.Dataset` """ data = self.client.post(self.api_path, json=creation_spec).successful().json() return Dataset.from_json(self.client, data) diff --git a/tamr_unify_client/models/dataset/profile.py b/tamr_unify_client/dataset/profile.py similarity index 94% rename from tamr_unify_client/models/dataset/profile.py rename to tamr_unify_client/dataset/profile.py index 8e908de1..a8783d84 100644 --- a/tamr_unify_client/models/dataset/profile.py +++ b/tamr_unify_client/dataset/profile.py @@ -69,11 +69,13 @@ def refresh(self, **options): """Updates the dataset profile if needed. The dataset profile is updated on the server; you will need to call - :func:`~tamr_unify_client.models.dataset.resource.Dataset.profile` + :func:`~tamr_unify_client.dataset.resource.Dataset.profile` to retrieve the updated profile. :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.operation.Operation` . See :func:`~tamr_unify_client.operation.Operation.apply_options` . + :returns: The refresh operation. + :rtype: :class:`~tamr_unify_client.operation.Operation` """ op_json = self.client.post(self.api_path + ":refresh").successful().json() op = Operation.from_json(self.client, op_json) diff --git a/tamr_unify_client/models/dataset/resource.py b/tamr_unify_client/dataset/resource.py similarity index 94% rename from tamr_unify_client/models/dataset/resource.py rename to tamr_unify_client/dataset/resource.py index fdb1c429..efaab741 100644 --- a/tamr_unify_client/models/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -2,9 +2,9 @@ from tamr_unify_client.attribute.collection import AttributeCollection from tamr_unify_client.base_resource import BaseResource -from tamr_unify_client.models.dataset.profile import DatasetProfile -from tamr_unify_client.models.dataset.status import DatasetStatus -from tamr_unify_client.models.dataset.usage import DatasetUsage +from tamr_unify_client.dataset.profile import DatasetProfile +from tamr_unify_client.dataset.status import DatasetStatus +from tamr_unify_client.dataset.usage import DatasetUsage from tamr_unify_client.operation import Operation @@ -57,8 +57,8 @@ def attributes(self): def _update_records(self, updates, **json_args): """Send a batch of record creations/updates/deletions to this dataset. - You probably want to use :func:`~tamr_unify_client.models.dataset.resource.Dataset.upsert_records` - or :func:`~tamr_unify_client.models.dataset.resource.Dataset.delete_records` instead. + You probably want to use :func:`~tamr_unify_client.dataset.resource.Dataset.upsert_records` + or :func:`~tamr_unify_client.dataset.resource.Dataset.delete_records` instead. :param records: Each record should be formatted as specified in the `Public Docs for Dataset updates `_. :type records: iterable[dict] @@ -125,8 +125,11 @@ def delete_records_by_id(self, record_ids): def refresh(self, **options): """Brings dataset up-to-date if needed, taking whatever actions are required. + :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.operation.Operation` . See :func:`~tamr_unify_client.operation.Operation.apply_options` . + :returns: The refresh operation. + :rtype: :class:`~tamr_unify_client.operation.Operation` """ op_json = self.client.post(self.api_path + ":refresh").successful().json() op = Operation.from_json(self.client, op_json) @@ -139,9 +142,8 @@ def profile(self): If the returned profile information is out-of-date, you can call refresh() on the returned object to bring it up-to-date. - :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.operation.Operation` . :return: Dataset Profile information. - :rtype: :class:`~tamr_unify_client.models.dataset.profile.DatasetProfile` + :rtype: :class:`~tamr_unify_client.dataset.profile.DatasetProfile` """ profile_json = self.client.get(self.api_path + "/profile").successful().json() return DatasetProfile.from_json( @@ -156,7 +158,8 @@ def create_profile(self, **options): :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.operation.Operation` . See :func:`~tamr_unify_client.operation.Operation.apply_options` . - :return: the operation to create the profile. + :return: The operation to create the profile. + :rtype: :class:`~tamr_unify_client.operation.Operation` """ op_json = ( self.client.post(self.api_path + "/profile:refresh").successful().json() @@ -178,7 +181,7 @@ def status(self): """Retrieve this dataset's streamability status. :return: Dataset streamability status. - :rtype: :class:`~tamr_unify_client.models.dataset.status.DatasetStatus` + :rtype: :class:`~tamr_unify_client.dataset.status.DatasetStatus` """ status_json = self.client.get(self.api_path + "/status").successful().json() return DatasetStatus.from_json( @@ -189,7 +192,7 @@ def usage(self): """Retrieve this dataset's usage by recipes and downstream datasets. :return: The dataset's usage. - :rtype: :class:`~tamr_unify_client.models.dataset.usage.DatasetUsage` + :rtype: :class:`~tamr_unify_client.dataset.usage.DatasetUsage` """ alias = self.api_path + "/usage" usage = self.client.get(alias).successful().json() @@ -239,7 +242,7 @@ def __geo_interface__(self): """Retrieve a representation of this dataset that conforms to the Python Geo Interface. Note that this materializes all features; for a streaming interface to features, - see :method:`~tamr_unify_client.models.dataset.Dataset.__geo_features__()` + see :method:`~tamr_unify_client.dataset.Dataset.__geo_features__()` See https://gist.github.com/sgillies/2217756 diff --git a/tamr_unify_client/models/dataset/status.py b/tamr_unify_client/dataset/status.py similarity index 100% rename from tamr_unify_client/models/dataset/status.py rename to tamr_unify_client/dataset/status.py diff --git a/tamr_unify_client/models/dataset/usage.py b/tamr_unify_client/dataset/usage.py similarity index 80% rename from tamr_unify_client/models/dataset/usage.py rename to tamr_unify_client/dataset/usage.py index a92c41ff..c580f8ce 100644 --- a/tamr_unify_client/models/dataset/usage.py +++ b/tamr_unify_client/dataset/usage.py @@ -1,5 +1,5 @@ from tamr_unify_client.base_resource import BaseResource -from tamr_unify_client.models.dataset.use import DatasetUse +from tamr_unify_client.dataset.use import DatasetUse class DatasetUsage(BaseResource): @@ -20,12 +20,12 @@ def relative_id(self): @property def usage(self): - """:type: :class:`~tamr_unify_client.models.dataset.use.DatasetUse`""" + """:type: :class:`~tamr_unify_client.dataset.use.DatasetUse`""" return DatasetUse(self.client, self._data.get("usage")) @property def dependencies(self): - """:type: list[:class:`~tamr_unify_client.models.dataset.use.DatasetUse`]""" + """:type: list[:class:`~tamr_unify_client.dataset.use.DatasetUse`]""" deps = self._data.get("dependencies") return [DatasetUse(self.client, dep) for dep in deps] diff --git a/tamr_unify_client/models/dataset/use.py b/tamr_unify_client/dataset/use.py similarity index 87% rename from tamr_unify_client/models/dataset/use.py rename to tamr_unify_client/dataset/use.py index d3504769..850fcf51 100644 --- a/tamr_unify_client/models/dataset/use.py +++ b/tamr_unify_client/dataset/use.py @@ -4,7 +4,7 @@ class DatasetUse: """ The use of a dataset in project steps. This is not a `BaseResource` because it has no API path - and cannot be directly retrieved or modified. + and cannot be directly retrieved or modified. See https://docs.tamr.com/reference#retrieve-downstream-dataset-usage @@ -41,10 +41,10 @@ def output_from_project_steps(self): return [ProjectStep(self.client, step) for step in steps] def dataset(self): - """Retrieves the :class:`~tamr_unify_client.models.dataset.resource.Dataset` this use represents. + """Retrieves the :class:`~tamr_unify_client.dataset.resource.Dataset` this use represents. :return: The dataset being used. - :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` + :rtype: :class:`~tamr_unify_client.dataset.resource.Dataset` """ dataset_id = self.dataset_id.split("/")[-1] return self.client.datasets.by_resource_id(dataset_id) diff --git a/tamr_unify_client/models/project/mastering.py b/tamr_unify_client/models/project/mastering.py index 5215a44a..b1d3012e 100644 --- a/tamr_unify_client/models/project/mastering.py +++ b/tamr_unify_client/models/project/mastering.py @@ -1,5 +1,5 @@ +from tamr_unify_client.dataset.resource import Dataset from tamr_unify_client.models.binning_model import BinningModel -from tamr_unify_client.models.dataset.resource import Dataset from tamr_unify_client.models.machine_learning_model import MachineLearningModel from tamr_unify_client.models.project.cluster_configuration import ( PublishedClustersConfiguration, @@ -15,11 +15,11 @@ def pairs(self): """Record pairs generated by Unify's binning model. Pairs are displayed on the "Pairs" page in the Unify UI. - Call :func:`~tamr_unify_client.models.dataset.resource.Dataset.refresh` from + Call :func:`~tamr_unify_client.dataset.resource.Dataset.refresh` from this dataset to regenerate pairs according to the latest binning model. :returns: The record pairs represented as a dataset. - :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` + :rtype: :class:`~tamr_unify_client.dataset.resource.Dataset` """ alias = self.api_path + "/recordPairs" return Dataset(self.client, None, alias) @@ -45,12 +45,12 @@ def high_impact_pairs(self): High-impact pairs are displayed with a ⚡ lightning bolt icon on the "Pairs" page in the Unify UI. - Call :func:`~tamr_unify_client.models.dataset.resource.Dataset.refresh` from + Call :func:`~tamr_unify_client.dataset.resource.Dataset.refresh` from this dataset to produce new high-impact pairs according to the latest pair-matching model. :returns: The high-impact pairs represented as a dataset. - :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` + :rtype: :class:`~tamr_unify_client.dataset.resource.Dataset` """ alias = self.api_path + "/highImpactPairs" return Dataset(self.client, None, alias) @@ -60,11 +60,11 @@ def record_clusters(self): model. These clusters populate the cluster review page and get transient cluster ids, rather than published cluster ids (i.e., "Permanent Ids") - Call :func:`~tamr_unify_client.models.dataset.resource.Dataset.refresh` from + Call :func:`~tamr_unify_client.dataset.resource.Dataset.refresh` from this dataset to generate clusters based on to the latest pair-matching model. :returns: The record clusters represented as a dataset. - :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` + :rtype: :class:`~tamr_unify_client.dataset.resource.Dataset` """ alias = self.api_path + "/recordClusters" return Dataset(self.client, None, alias) @@ -73,7 +73,7 @@ def published_clusters(self): """Published record clusters generated by Unify's pair-matching model. :returns: The published clusters represented as a dataset. - :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` + :rtype: :class:`~tamr_unify_client.dataset.resource.Dataset` """ unified_dataset = self.unified_dataset() @@ -104,7 +104,7 @@ def published_cluster_ids(self): """Retrieves published cluster IDs for this project. :returns: The published cluster ID dataset. - :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` + :rtype: :class:`~tamr_unify_client.dataset.resource.Dataset` """ # Replace this workaround with a direct API call once API # is fixed. APIs that need to work are: fetching the dataset and @@ -121,7 +121,7 @@ def published_cluster_stats(self): """Retrieves published cluster stats for this project. :returns: The published cluster stats dataset. - :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` + :rtype: :class:`~tamr_unify_client.dataset.resource.Dataset` """ # Replace this workaround with a direct API call once API # is fixed. APIs that need to work are: fetching the dataset and @@ -149,7 +149,7 @@ def record_clusters_with_data(self): """Project's unified dataset with associated clusters. :returns: The record clusters with data represented as a dataset - :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` + :rtype: :class:`~tamr_unify_client.dataset.resource.Dataset` """ unified_dataset = self.unified_dataset() @@ -165,7 +165,7 @@ def record_clusters_with_data(self): def published_clusters_with_data(self): """Project's unified dataset with associated clusters. :returns: The published clusters with data represented as a dataset - :rtype :class `~tamr_unify_client.models.dataset.resource.Dataset` + :rtype :class `~tamr_unify_client.dataset.resource.Dataset` """ unified_dataset = self.unified_dataset() diff --git a/tamr_unify_client/models/project/resource.py b/tamr_unify_client/models/project/resource.py index a24e2428..96fd4086 100644 --- a/tamr_unify_client/models/project/resource.py +++ b/tamr_unify_client/models/project/resource.py @@ -1,9 +1,9 @@ from tamr_unify_client.base_resource import BaseResource +from tamr_unify_client.dataset.collection import DatasetCollection +from tamr_unify_client.dataset.resource import Dataset from tamr_unify_client.models.attribute_configuration.collection import ( AttributeConfigurationCollection, ) -from tamr_unify_client.models.dataset.collection import DatasetCollection -from tamr_unify_client.models.dataset.resource import Dataset class Project(BaseResource): @@ -56,7 +56,7 @@ def unified_dataset(self): """Unified dataset for this project. :return: Unified dataset for this project. - :rtype: :class:`~tamr_unify_client.models.dataset.resource.Dataset` + :rtype: :class:`~tamr_unify_client.dataset.resource.Dataset` """ alias = self.api_path + "/unifiedDataset" resource_json = self.client.get(alias).successful().json() @@ -102,8 +102,8 @@ def add_input_dataset(self, dataset): They need to be added as input to a project before they can be used as part of that project - :param project: Unify Project - :param dataset: Unify Dataset + :param dataset: The dataset to associate with the project. + :type dataset: :class:`~tamr_unify_client.dataset.resource.Dataset` :return: HTTP response from the server :rtype: :class:`requests.Response` """ @@ -117,7 +117,7 @@ def input_datasets(self): """Retrieve a collection of this project's input datasets. :return: The project's input datasets. - :rtype: :class: `~tamr_unify_client.models.dataset.collection.DatasetCollection` + :rtype: :class:`~tamr_unify_client.dataset.collection.DatasetCollection` """ alias = self.api_path + "/inputDatasets" return DatasetCollection(self.client, alias) diff --git a/tamr_unify_client/operation.py b/tamr_unify_client/operation.py index 509e5fcd..2c3a2de4 100644 --- a/tamr_unify_client/operation.py +++ b/tamr_unify_client/operation.py @@ -22,7 +22,7 @@ def apply_options(self, asynchronous=False, **options): """Applies operation options to this operation. **NOTE**: This function **should not** be called directly. Rather, options should be - passed in through a higher-level function e.g. :func:`~tamr_unify_client.models.dataset.resource.Dataset.refresh` . + passed in through a higher-level function e.g. :func:`~tamr_unify_client.dataset.resource.Dataset.refresh` . Synchronous mode: Automatically waits for operation to resolve before returning the diff --git a/tests/unit/test_dataset_geo.py b/tests/unit/test_dataset_geo.py index fd2ceac5..16199f24 100644 --- a/tests/unit/test_dataset_geo.py +++ b/tests/unit/test_dataset_geo.py @@ -8,7 +8,7 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth -from tamr_unify_client.models.dataset.resource import Dataset +from tamr_unify_client.dataset.resource import Dataset class TestDatasetGeo(TestCase): diff --git a/tests/unit/test_dataset_usage.py b/tests/unit/test_dataset_usage.py index 8cdb61f2..55a1da77 100644 --- a/tests/unit/test_dataset_usage.py +++ b/tests/unit/test_dataset_usage.py @@ -4,9 +4,9 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth -from tamr_unify_client.models.dataset.resource import Dataset -from tamr_unify_client.models.dataset.usage import DatasetUsage -from tamr_unify_client.models.dataset.use import DatasetUse +from tamr_unify_client.dataset.resource import Dataset +from tamr_unify_client.dataset.usage import DatasetUsage +from tamr_unify_client.dataset.use import DatasetUse from tamr_unify_client.models.project.step import ProjectStep diff --git a/tests/unit/test_strings.py b/tests/unit/test_strings.py index e87b7252..14d6f277 100644 --- a/tests/unit/test_strings.py +++ b/tests/unit/test_strings.py @@ -1,6 +1,6 @@ from tamr_unify_client import Client from tamr_unify_client.auth import TokenAuth, UsernamePasswordAuth -from tamr_unify_client.models.dataset.status import DatasetStatus +from tamr_unify_client.dataset.status import DatasetStatus def test_client_repr(): @@ -55,7 +55,7 @@ def test_dataset_status_repr(): "isStreamable": True, } status = DatasetStatus.from_json(client, data) - full_clz_name = "tamr_unify_client.models.dataset.status.DatasetStatus" + full_clz_name = "tamr_unify_client.dataset.status.DatasetStatus" rstr = f"{status!r}" @@ -68,7 +68,7 @@ def test_dataset_status_repr(): def test_dataset_collection_repr(): client = Client(UsernamePasswordAuth("username", "password")) - full_clz_name = "tamr_unify_client.models.dataset.collection.DatasetCollection" + full_clz_name = "tamr_unify_client.dataset.collection.DatasetCollection" rstr = f"{client.datasets!r}" From 0af05630f5d066b918a8151921a1ebe139d6a963 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 25 Jul 2019 10:28:27 -0400 Subject: [PATCH 096/632] refactor project --- docs/developer-interface.rst | 16 ++++++------- tamr_unify_client/client.py | 4 ++-- tamr_unify_client/dataset/use.py | 6 ++--- .../{models => }/project/__init__.py | 0 .../{models => }/project/categorization.py | 2 +- .../project/cluster_configuration.py | 0 .../{models => }/project/collection.py | 14 +++++------ .../project/estimated_pair_counts.py | 4 +++- .../{models => }/project/mastering.py | 13 +++++----- .../{models => }/project/resource.py | 24 +++++++------------ .../{models => }/project/step.py | 8 +++---- tests/unit/test_dataset_usage.py | 2 +- tests/unit/test_pair_counts.py | 4 ++-- tests/unit/test_project.py | 2 +- tests/unit/test_published_clusters.py | 4 ++-- 15 files changed, 50 insertions(+), 53 deletions(-) rename tamr_unify_client/{models => }/project/__init__.py (100%) rename tamr_unify_client/{models => }/project/categorization.py (96%) rename tamr_unify_client/{models => }/project/cluster_configuration.py (100%) rename tamr_unify_client/{models => }/project/collection.py (83%) rename tamr_unify_client/{models => }/project/estimated_pair_counts.py (93%) rename tamr_unify_client/{models => }/project/mastering.py (94%) rename tamr_unify_client/{models => }/project/resource.py (82%) rename tamr_unify_client/{models => }/project/step.py (84%) diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index 43b14619..c4f17663 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -90,7 +90,7 @@ Attribute Configurations Estimated Pair Counts --------------------- -.. autoclass:: tamr_unify_client.models.project.estimated_pair_counts.EstimatedPairCounts +.. autoclass:: tamr_unify_client.project.estimated_pair_counts.EstimatedPairCounts :members: Machine Learning Models @@ -109,38 +109,38 @@ Operations Project ------- -.. autoclass:: tamr_unify_client.models.project.resource.Project +.. autoclass:: tamr_unify_client.project.resource.Project :members: ---- -.. autoclass:: tamr_unify_client.models.project.categorization.CategorizationProject +.. autoclass:: tamr_unify_client.project.categorization.CategorizationProject :members: ---- -.. autoclass:: tamr_unify_client.models.project.mastering.MasteringProject +.. autoclass:: tamr_unify_client.project.mastering.MasteringProject :members: ---- -.. autoclass:: tamr_unify_client.models.project.step.ProjectStep +.. autoclass:: tamr_unify_client.project.step.ProjectStep :members: ---- -.. autoclass:: tamr_unify_client.models.project.estimated_pair_counts.EstimatedPairCounts +.. autoclass:: tamr_unify_client.project.estimated_pair_counts.EstimatedPairCounts :members: ---- -.. autoclass:: tamr_unify_client.models.project.cluster_configuration.PublishedClustersConfiguration +.. autoclass:: tamr_unify_client.project.cluster_configuration.PublishedClustersConfiguration :members: Projects -------- -.. autoclass:: tamr_unify_client.models.project.collection.ProjectCollection +.. autoclass:: tamr_unify_client.project.collection.ProjectCollection :members: Taxonomy diff --git a/tamr_unify_client/client.py b/tamr_unify_client/client.py index 7c5045d9..7c834f09 100644 --- a/tamr_unify_client/client.py +++ b/tamr_unify_client/client.py @@ -4,7 +4,7 @@ from requests import Response from tamr_unify_client.dataset.collection import DatasetCollection -from tamr_unify_client.models.project.collection import ProjectCollection +from tamr_unify_client.project.collection import ProjectCollection # monkey-patch Response.successful @@ -138,7 +138,7 @@ def projects(self): """Collection of all projects on this Unify instance. :return: Collection of all projects. - :rtype: :class:`~tamr_unify_client.models.ProjectCollection` + :rtype: :class:`~tamr_unify_client.project.collection.ProjectCollection` """ return self._projects diff --git a/tamr_unify_client/dataset/use.py b/tamr_unify_client/dataset/use.py index 850fcf51..2b732a22 100644 --- a/tamr_unify_client/dataset/use.py +++ b/tamr_unify_client/dataset/use.py @@ -1,4 +1,4 @@ -from tamr_unify_client.models.project.step import ProjectStep +from tamr_unify_client.project.step import ProjectStep class DatasetUse: @@ -30,13 +30,13 @@ def dataset_name(self): @property def input_to_project_steps(self): - """:type: list[:class:`~tamr_unify_client.models.project.step.ProjectStep`]""" + """:type: list[:class:`~tamr_unify_client.project.step.ProjectStep`]""" steps = self._data.get("inputToProjectSteps") return [ProjectStep(self.client, step) for step in steps] @property def output_from_project_steps(self): - """:type: list[:class:`~tamr_unify_client.models.project.step.ProjectStep`]""" + """:type: list[:class:`~tamr_unify_client.project.step.ProjectStep`]""" steps = self._data.get("outputFromProjectSteps") return [ProjectStep(self.client, step) for step in steps] diff --git a/tamr_unify_client/models/project/__init__.py b/tamr_unify_client/project/__init__.py similarity index 100% rename from tamr_unify_client/models/project/__init__.py rename to tamr_unify_client/project/__init__.py diff --git a/tamr_unify_client/models/project/categorization.py b/tamr_unify_client/project/categorization.py similarity index 96% rename from tamr_unify_client/models/project/categorization.py rename to tamr_unify_client/project/categorization.py index 47ef6c9a..9b3425b0 100644 --- a/tamr_unify_client/models/project/categorization.py +++ b/tamr_unify_client/project/categorization.py @@ -1,6 +1,6 @@ from tamr_unify_client.models.machine_learning_model import MachineLearningModel -from tamr_unify_client.models.project.resource import Project from tamr_unify_client.models.taxonomy.resource import Taxonomy +from tamr_unify_client.project.resource import Project class CategorizationProject(Project): diff --git a/tamr_unify_client/models/project/cluster_configuration.py b/tamr_unify_client/project/cluster_configuration.py similarity index 100% rename from tamr_unify_client/models/project/cluster_configuration.py rename to tamr_unify_client/project/cluster_configuration.py diff --git a/tamr_unify_client/models/project/collection.py b/tamr_unify_client/project/collection.py similarity index 83% rename from tamr_unify_client/models/project/collection.py rename to tamr_unify_client/project/collection.py index 0221a318..765aae73 100644 --- a/tamr_unify_client/models/project/collection.py +++ b/tamr_unify_client/project/collection.py @@ -1,9 +1,9 @@ from tamr_unify_client.base_collection import BaseCollection -from tamr_unify_client.models.project.resource import Project +from tamr_unify_client.project.resource import Project class ProjectCollection(BaseCollection): - """Collection of :class:`~tamr_unify_client.models.project.resource.Project` s. + """Collection of :class:`~tamr_unify_client.project.resource.Project` s. :param client: Client for API call delegation. :type client: :class:`~tamr_unify_client.Client` @@ -21,7 +21,7 @@ def by_resource_id(self, resource_id): :param resource_id: The resource ID. E.g. ``"1"`` :type resource_id: str :returns: The specified project. - :rtype: :class:`~tamr_unify_client.models.project.resource.Project` + :rtype: :class:`~tamr_unify_client.project.resource.Project` """ return super().by_resource_id("projects", resource_id) @@ -31,7 +31,7 @@ def by_relative_id(self, relative_id): :param relative_id: The resource ID. E.g. ``"projects/1"`` :type relative_id: str :returns: The specified project. - :rtype: :class:`~tamr_unify_client.models.project.resource.Project` + :rtype: :class:`~tamr_unify_client.project.resource.Project` """ return super().by_relative_id(Project, relative_id) @@ -41,7 +41,7 @@ def by_external_id(self, external_id): :param external_id: The external ID. :type external_id: str :returns: The specified project, if found. - :rtype: :class:`~tamr_unify_client.models.project.resource.Project` + :rtype: :class:`~tamr_unify_client.project.resource.Project` :raises KeyError: If no project with the specified external_id is found :raises LookupError: If multiple projects with the specified external_id are found """ @@ -52,7 +52,7 @@ def stream(self): over this collection. :returns: Stream of projects. - :rtype: Python generator yielding :class:`~tamr_unify_client.models.project.resource.Project` + :rtype: Python generator yielding :class:`~tamr_unify_client.project.resource.Project` Usage: >>> for project in collection.stream(): # explicit @@ -69,7 +69,7 @@ def create(self, creation_spec): :param creation_spec: Project creation specification should be formatted as specified in the `Public Docs for Creating a Project `_. :type creation_spec: dict[str, str] :returns: The created Project - :rtype: :class:`~tamr_unify_client.models.project.resource.Project` + :rtype: :class:`~tamr_unify_client.project.resource.Project` """ data = self.client.post(self.api_path, json=creation_spec).successful().json() return Project.from_json(self.client, data) diff --git a/tamr_unify_client/models/project/estimated_pair_counts.py b/tamr_unify_client/project/estimated_pair_counts.py similarity index 93% rename from tamr_unify_client/models/project/estimated_pair_counts.py rename to tamr_unify_client/project/estimated_pair_counts.py index 4b32a07b..1483ccb6 100644 --- a/tamr_unify_client/models/project/estimated_pair_counts.py +++ b/tamr_unify_client/project/estimated_pair_counts.py @@ -55,11 +55,13 @@ def refresh(self, **options): """Updates the estimated pair counts if needed. The pair count estimates are updated on the server; you will need to call - :func:`~tamr_unify_client.models.project.mastering.MasteringProject.estimate_pairs` + :func:`~tamr_unify_client.project.mastering.MasteringProject.estimate_pairs` to retrieve the updated estimate. :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.operation.Operation` . See :func:`~tamr_unify_client.operation.Operation.apply_options` . + :returns: The refresh operation. + :rtype: :class:`~tamr_unify_client.operation.Operation` """ op_json = self.client.post(self.api_path + ":refresh").successful().json() op = Operation.from_json(self.client, op_json) diff --git a/tamr_unify_client/models/project/mastering.py b/tamr_unify_client/project/mastering.py similarity index 94% rename from tamr_unify_client/models/project/mastering.py rename to tamr_unify_client/project/mastering.py index b1d3012e..74f13da4 100644 --- a/tamr_unify_client/models/project/mastering.py +++ b/tamr_unify_client/project/mastering.py @@ -1,11 +1,11 @@ from tamr_unify_client.dataset.resource import Dataset from tamr_unify_client.models.binning_model import BinningModel from tamr_unify_client.models.machine_learning_model import MachineLearningModel -from tamr_unify_client.models.project.cluster_configuration import ( +from tamr_unify_client.project.cluster_configuration import ( PublishedClustersConfiguration, ) -from tamr_unify_client.models.project.estimated_pair_counts import EstimatedPairCounts -from tamr_unify_client.models.project.resource import Project +from tamr_unify_client.project.estimated_pair_counts import EstimatedPairCounts +from tamr_unify_client.project.resource import Project class MasteringProject(Project): @@ -92,7 +92,7 @@ def published_clusters_configuration(self): """Retrieves published clusters configuration for this project. :returns: The published clusters configuration - :rtype: :class:`~tamr_unify_client.models.project.cluster_configuration.PublishedClustersConfiguration` + :rtype: :class:`~tamr_unify_client.project.cluster_configuration.PublishedClustersConfiguration` """ alias = self.api_path + "/publishedClustersConfiguration" resource_json = self.client.get(alias).successful().json() @@ -138,7 +138,7 @@ def estimate_pairs(self): """Returns pair estimate information for a mastering project :return: Pairs Estimate information. - :rtype: :class:`~tamr_unify_client.models.project.estimated_pair_counts.EstimatedPairCounts` + :rtype: :class:`~tamr_unify_client.project.estimated_pair_counts.EstimatedPairCounts` """ alias = self.api_path + "/estimatedPairCounts" estimate_json = self.client.get(alias).successful().json() @@ -164,8 +164,9 @@ def record_clusters_with_data(self): def published_clusters_with_data(self): """Project's unified dataset with associated clusters. + :returns: The published clusters with data represented as a dataset - :rtype :class `~tamr_unify_client.dataset.resource.Dataset` + :rtype: :class:`~tamr_unify_client.dataset.resource.Dataset` """ unified_dataset = self.unified_dataset() diff --git a/tamr_unify_client/models/project/resource.py b/tamr_unify_client/project/resource.py similarity index 82% rename from tamr_unify_client/models/project/resource.py rename to tamr_unify_client/project/resource.py index 96fd4086..0e5ee646 100644 --- a/tamr_unify_client/models/project/resource.py +++ b/tamr_unify_client/project/resource.py @@ -30,11 +30,7 @@ def description(self): @property def type(self): - """One of: - ``"SCHEMA_MAPPING"`` - ``"SCHEMA_MAPPING_RECOMMENDATIONS"`` - ``"CATEGORIZATION"`` - ``"DEDUP"`` + """A Unify project type, listed in https://docs.tamr.com/reference#create-a-project. :type: str """ @@ -63,15 +59,13 @@ def unified_dataset(self): return Dataset.from_json(self.client, resource_json, alias) def as_categorization(self): - """Convert this project to a :class:`~tamr_unify_client.models.project.categorization.CategorizationProject` + """Convert this project to a :class:`~tamr_unify_client.project.categorization.CategorizationProject` :return: This project. - :rtype: :class:`~tamr_unify_client.models.project.categorization.CategorizationProject` - :raises TypeError: If the :attr:`~tamr_unify_client.models.project.resource.Project.type` of this project is not ``"CATEGORIZATION"`` + :rtype: :class:`~tamr_unify_client.project.categorization.CategorizationProject` + :raises TypeError: If the :attr:`~tamr_unify_client.project.resource.Project.type` of this project is not ``"CATEGORIZATION"`` """ - from tamr_unify_client.models.project.categorization import ( - CategorizationProject, - ) + from tamr_unify_client.project.categorization import CategorizationProject if self.type != "CATEGORIZATION": raise TypeError( @@ -80,13 +74,13 @@ def as_categorization(self): return CategorizationProject(self.client, self._data, self.api_path) def as_mastering(self): - """Convert this project to a :class:`~tamr_unify_client.models.project.mastering.MasteringProject` + """Convert this project to a :class:`~tamr_unify_client.project.mastering.MasteringProject` :return: This project. - :rtype: :class:`~tamr_unify_client.models.project.mastering.MasteringProject` - :raises TypeError: If the :attr:`~tamr_unify_client.models.project.resource.Project.type` of this project is not ``"DEDUP"`` + :rtype: :class:`~tamr_unify_client.project.mastering.MasteringProject` + :raises TypeError: If the :attr:`~tamr_unify_client.project.resource.Project.type` of this project is not ``"DEDUP"`` """ - from tamr_unify_client.models.project.mastering import MasteringProject + from tamr_unify_client.project.mastering import MasteringProject if self.type != "DEDUP": raise TypeError( diff --git a/tamr_unify_client/models/project/step.py b/tamr_unify_client/project/step.py similarity index 84% rename from tamr_unify_client/models/project/step.py rename to tamr_unify_client/project/step.py index 41a1a245..52936685 100644 --- a/tamr_unify_client/models/project/step.py +++ b/tamr_unify_client/project/step.py @@ -1,6 +1,6 @@ class ProjectStep: - """ A step of a Unify project. This is not a `BaseResource` because it has no API path - and cannot be directly retrieved or modified. + """A step of a Unify project. This is not a `BaseResource` because it has no API path + and cannot be directly retrieved or modified. See https://docs.tamr.com/reference#retrieve-downstream-dataset-usage @@ -37,10 +37,10 @@ def type(self): return self._data.get("type") def project(self): - """Retrieves the :class:`~tamr_unify_client.models.project.resource.Project` this step is associated with. + """Retrieves the :class:`~tamr_unify_client.project.resource.Project` this step is associated with. :returns: This step's project. - :rtype: :class:`~tamr_unify_client.models.project.resource.Project` + :rtype: :class:`~tamr_unify_client.project.resource.Project` :raises KeyError: If no project with the specified name is found. :raises LookupError: If multiple projects with the specified name are found. """ diff --git a/tests/unit/test_dataset_usage.py b/tests/unit/test_dataset_usage.py index 55a1da77..63622a33 100644 --- a/tests/unit/test_dataset_usage.py +++ b/tests/unit/test_dataset_usage.py @@ -7,7 +7,7 @@ from tamr_unify_client.dataset.resource import Dataset from tamr_unify_client.dataset.usage import DatasetUsage from tamr_unify_client.dataset.use import DatasetUse -from tamr_unify_client.models.project.step import ProjectStep +from tamr_unify_client.project.step import ProjectStep class TestUsage(TestCase): diff --git a/tests/unit/test_pair_counts.py b/tests/unit/test_pair_counts.py index d4e7ba02..4bbedcb9 100644 --- a/tests/unit/test_pair_counts.py +++ b/tests/unit/test_pair_counts.py @@ -4,9 +4,9 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth -from tamr_unify_client.models.project.estimated_pair_counts import EstimatedPairCounts -from tamr_unify_client.models.project.mastering import MasteringProject from tamr_unify_client.operation import Operation +from tamr_unify_client.project.estimated_pair_counts import EstimatedPairCounts +from tamr_unify_client.project.mastering import MasteringProject class TestPairCounts(TestCase): diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index 049cba3b..068f612e 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -4,7 +4,7 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth -from tamr_unify_client.models.project.resource import Project +from tamr_unify_client.project.resource import Project class TestProject(TestCase): diff --git a/tests/unit/test_published_clusters.py b/tests/unit/test_published_clusters.py index ad4c557d..0d1c6816 100644 --- a/tests/unit/test_published_clusters.py +++ b/tests/unit/test_published_clusters.py @@ -4,10 +4,10 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth -from tamr_unify_client.models.project.cluster_configuration import ( +from tamr_unify_client.project.cluster_configuration import ( PublishedClustersConfiguration, ) -from tamr_unify_client.models.project.resource import Project +from tamr_unify_client.project.resource import Project class PublishedClusterTest(TestCase): From 6dc143739de6176c1f3b0a5152fe3d74a7d41de7 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 25 Jul 2019 13:13:23 -0400 Subject: [PATCH 097/632] refactor taxonomy --- docs/developer-interface.rst | 2 +- tamr_unify_client/project/categorization.py | 16 ++++++++-------- .../{models => }/taxonomy/__init__.py | 0 .../{models => }/taxonomy/resource.py | 0 tests/unit/test_taxonomy.py | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) rename tamr_unify_client/{models => }/taxonomy/__init__.py (100%) rename tamr_unify_client/{models => }/taxonomy/resource.py (100%) diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index c4f17663..33ec82a6 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -146,5 +146,5 @@ Projects Taxonomy -------- -.. autoclass:: tamr_unify_client.models.taxonomy.resource.Taxonomy +.. autoclass:: tamr_unify_client.taxonomy.resource.Taxonomy :members: \ No newline at end of file diff --git a/tamr_unify_client/project/categorization.py b/tamr_unify_client/project/categorization.py index 9b3425b0..897f71de 100644 --- a/tamr_unify_client/project/categorization.py +++ b/tamr_unify_client/project/categorization.py @@ -1,6 +1,6 @@ from tamr_unify_client.models.machine_learning_model import MachineLearningModel -from tamr_unify_client.models.taxonomy.resource import Taxonomy from tamr_unify_client.project.resource import Project +from tamr_unify_client.taxonomy.resource import Taxonomy class CategorizationProject(Project): @@ -17,26 +17,26 @@ def model(self): return MachineLearningModel(self.client, None, alias) def create_taxonomy(self, creation_spec): - """Creates a Taxonomy for this Categorization project. + """Creates a :class:`~tamr_unify_client.taxonomy.resource.Taxonomy` for this project. A taxonomy cannot already be associated with this project. :param creation_spec: The creation specification for the taxonomy, which can include name. - :type: dict + :type creation_spec: dict :returns: The new Taxonomy - :rtype: :class:`~tamr_unify_client.models.taxonomy.resource.Taxonomy` + :rtype: :class:`~tamr_unify_client.taxonomy.resource.Taxonomy` """ alias = self.api_path + "/taxonomy" resource_json = self.client.post(alias, json=creation_spec).successful().json() return Taxonomy.from_json(self.client, resource_json, alias) def taxonomy(self): - """Retrieves the Taxonomy associated with Categorization project. - - If a taxonomy is not already associated with this project, call create_taxonomy() first. + """Retrieves the :class:`~tamr_unify_client.taxonomy.resource.Taxonomy` associated with this project. + If a taxonomy is not already associated with this project, + call :func:`~tamr_unify_client.project.categorization.CategorizationProject.create_taxonomy` first. :returns: The project's Taxonomy - :rtype: :class:`~tamr_unify_client.models.taxonomy.resource.Taxonomy` + :rtype: :class:`~tamr_unify_client.taxonomy.resource.Taxonomy` """ alias = self.api_path + "/taxonomy" resource_json = self.client.get(alias).successful().json() diff --git a/tamr_unify_client/models/taxonomy/__init__.py b/tamr_unify_client/taxonomy/__init__.py similarity index 100% rename from tamr_unify_client/models/taxonomy/__init__.py rename to tamr_unify_client/taxonomy/__init__.py diff --git a/tamr_unify_client/models/taxonomy/resource.py b/tamr_unify_client/taxonomy/resource.py similarity index 100% rename from tamr_unify_client/models/taxonomy/resource.py rename to tamr_unify_client/taxonomy/resource.py diff --git a/tests/unit/test_taxonomy.py b/tests/unit/test_taxonomy.py index 1217b207..944190a8 100644 --- a/tests/unit/test_taxonomy.py +++ b/tests/unit/test_taxonomy.py @@ -8,7 +8,7 @@ from tamr_unify_client.auth import UsernamePasswordAuth from tamr_unify_client.category.collection import CategoryCollection from tamr_unify_client.category.resource import Category -from tamr_unify_client.models.taxonomy.resource import Taxonomy +from tamr_unify_client.taxonomy.resource import Taxonomy class TestTaxonomy(TestCase): From 1a29a4a2ea2f3320d49601ee7c09e74a4e19b633 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 25 Jul 2019 14:52:19 -0400 Subject: [PATCH 098/632] refactor attribute configuration to project --- docs/developer-interface.rst | 8 +++--- .../attribute_configuration/__init__.py | 0 .../attribute_configuration/collection.py | 28 +++++++++++-------- .../attribute_configuration/resource.py | 0 tamr_unify_client/project/resource.py | 9 +++--- tests/unit/test_attribute_configuration.py | 2 +- ...test_attribute_configuration_collection.py | 2 +- 7 files changed, 27 insertions(+), 22 deletions(-) rename tamr_unify_client/{models => project}/attribute_configuration/__init__.py (100%) rename tamr_unify_client/{models => project}/attribute_configuration/collection.py (72%) rename tamr_unify_client/{models => project}/attribute_configuration/resource.py (100%) diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index 33ec82a6..a1777ba5 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -78,14 +78,14 @@ Attributes .. autoclass:: tamr_unify_client.attribute.collection.AttributeCollection Attribute Configuration ----------- +----------------------- -.. autoclass:: tamr_unify_client.models.attribute_configuration.resource.AttributeConfiguration +.. autoclass:: tamr_unify_client.project.attribute_configuration.resource.AttributeConfiguration Attribute Configurations ----------- +------------------------ -.. autoclass:: tamr_unify_client.models.attribute_configuration.collection.AttributeConfigurationCollection +.. autoclass:: tamr_unify_client.project.attribute_configuration.collection.AttributeConfigurationCollection Estimated Pair Counts --------------------- diff --git a/tamr_unify_client/models/attribute_configuration/__init__.py b/tamr_unify_client/project/attribute_configuration/__init__.py similarity index 100% rename from tamr_unify_client/models/attribute_configuration/__init__.py rename to tamr_unify_client/project/attribute_configuration/__init__.py diff --git a/tamr_unify_client/models/attribute_configuration/collection.py b/tamr_unify_client/project/attribute_configuration/collection.py similarity index 72% rename from tamr_unify_client/models/attribute_configuration/collection.py rename to tamr_unify_client/project/attribute_configuration/collection.py index b5ecb1be..7e28163f 100644 --- a/tamr_unify_client/models/attribute_configuration/collection.py +++ b/tamr_unify_client/project/attribute_configuration/collection.py @@ -1,11 +1,12 @@ from tamr_unify_client.base_collection import BaseCollection -from tamr_unify_client.models.attribute_configuration.resource import ( +from tamr_unify_client.project.attribute_configuration.resource import ( AttributeConfiguration, ) class AttributeConfigurationCollection(BaseCollection): - """Collection of :class`~tamr_unify_client.models.attribute_configuration.resource.AttributeConfiguration~` + """Collection of :class:`~tamr_unify_client.project.attribute_configuration.resource.AttributeConfiguration` + :param client: Client for API call delegation. :type client: :class:`~tamr_unify_client.Client` :param api_path: API path used to access this collection. @@ -15,19 +16,21 @@ class AttributeConfigurationCollection(BaseCollection): def by_resource_id(self, resource_id): """Retrieve an attribute configuration by resource ID. + :param resource_id: The resource ID. :type resource_id: str :returns: The specified attribute configuration. - :rtype: :class:`~tamr_unify_client.models.attribute_configuration.resource.AttributeConfiguration` + :rtype: :class:`~tamr_unify_client.project.attribute_configuration.resource.AttributeConfiguration` """ return super().by_resource_id(self.api_path, resource_id) def by_relative_id(self, relative_id): """Retrieve an attribute configuration by relative ID. + :param relative_id: The relative ID. :type relative_id: str :returns: The specified attribute configuration. - :rtype: :class:`~tamr_unify_client.models.attribute_configuration.resource.AttributeConfiguration` + :rtype: :class:`~tamr_unify_client.project.attribute_configuration.resource.AttributeConfiguration` """ return super().by_relative_id(AttributeConfiguration, relative_id) @@ -40,7 +43,7 @@ def by_external_id(self, external_id): :param external_id: The external ID. :type external_id: str :returns: The specified attribute, if found. - :rtype: :class:`~tamr_unify_client.models.attribute_configuration.resource.AttributeConfiguration` + :rtype: :class:`~tamr_unify_client.project.attribute_configuration.resource.AttributeConfiguration` :raises KeyError: If no attribute with the specified external_id is found :raises LookupError: If multiple attributes with the specified external_id are found :raises NotImplementedError: AttributeConfiguration does not support external_id @@ -52,7 +55,7 @@ def stream(self): over this collection. :returns: Stream of attribute configurations. - :rtype: Python generator yielding :class:`~tamr_unify_client.models.attribute_configuration.resource.AttributeConfiguration` + :rtype: Python generator yielding :class:`~tamr_unify_client.project.attribute_configuration.resource.AttributeConfiguration` Usage: >>> for attributeConfiguration in collection.stream(): # explicit @@ -65,11 +68,12 @@ def stream(self): def create(self, creation_spec): """Create an Attribute configuration in this collection - :param creation_spec: Attribute configuration creation specification should be formatted as specified in the - `Public Docs for adding an AttributeConfiguration `_. - :type creation_spec: dict[str, str] - :returns: The created Attribute configuration - :rtype: :class:`~tamr_unify_client.models.attribute_configuration.resource.AttributeConfiguration` - """ + + :param creation_spec: Attribute configuration creation specification should be formatted as specified in the + `Public Docs for adding an AttributeConfiguration `_. + :type creation_spec: dict[str, str] + :returns: The created Attribute configuration + :rtype: :class:`~tamr_unify_client.project.attribute_configuration.resource.AttributeConfiguration` + """ data = self.client.post(self.api_path, json=creation_spec).successful().json() return AttributeConfiguration.from_json(self.client, data) diff --git a/tamr_unify_client/models/attribute_configuration/resource.py b/tamr_unify_client/project/attribute_configuration/resource.py similarity index 100% rename from tamr_unify_client/models/attribute_configuration/resource.py rename to tamr_unify_client/project/attribute_configuration/resource.py diff --git a/tamr_unify_client/project/resource.py b/tamr_unify_client/project/resource.py index 0e5ee646..79b02be6 100644 --- a/tamr_unify_client/project/resource.py +++ b/tamr_unify_client/project/resource.py @@ -1,7 +1,7 @@ from tamr_unify_client.base_resource import BaseResource from tamr_unify_client.dataset.collection import DatasetCollection from tamr_unify_client.dataset.resource import Dataset -from tamr_unify_client.models.attribute_configuration.collection import ( +from tamr_unify_client.project.attribute_configuration.collection import ( AttributeConfigurationCollection, ) @@ -117,9 +117,10 @@ def input_datasets(self): return DatasetCollection(self.client, alias) def attribute_configurations(self): - """ Project's attribute's configurations. - :returns: the configurations of the attributes of a project - :rtype :class: `~tamr_unify_client.models.attribute_configuration.collection.AttributeConfigurationCollection` + """Project's attribute's configurations. + + :returns: The configurations of the attributes of a project. + :rtype: :class:`~tamr_unify_client.project.attribute_configuration.collection.AttributeConfigurationCollection` """ alias = self.api_path + "/attributeConfigurations" info = AttributeConfigurationCollection(self.client, api_path=alias) diff --git a/tests/unit/test_attribute_configuration.py b/tests/unit/test_attribute_configuration.py index e3953ea8..86ef569f 100644 --- a/tests/unit/test_attribute_configuration.py +++ b/tests/unit/test_attribute_configuration.py @@ -2,7 +2,7 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth -from tamr_unify_client.models.attribute_configuration.resource import ( +from tamr_unify_client.project.attribute_configuration.resource import ( AttributeConfiguration, ) diff --git a/tests/unit/test_attribute_configuration_collection.py b/tests/unit/test_attribute_configuration_collection.py index 24c4e245..99c1d66b 100644 --- a/tests/unit/test_attribute_configuration_collection.py +++ b/tests/unit/test_attribute_configuration_collection.py @@ -4,7 +4,7 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth -from tamr_unify_client.models.attribute_configuration.collection import ( +from tamr_unify_client.project.attribute_configuration.collection import ( AttributeConfigurationCollection, ) From bc42cc275b648e9e56130ab3966ee153856c42bf Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 26 Jul 2019 11:44:00 -0400 Subject: [PATCH 099/632] refactor machine learning model to base model --- docs/developer-interface.rst | 2 +- .../{models/machine_learning_model.py => base_model.py} | 0 tamr_unify_client/project/categorization.py | 4 ++-- tamr_unify_client/project/mastering.py | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) rename tamr_unify_client/{models/machine_learning_model.py => base_model.py} (100%) diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index a1777ba5..7e3338d8 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -96,7 +96,7 @@ Estimated Pair Counts Machine Learning Models ----------------------- -.. autoclass:: tamr_unify_client.models.machine_learning_model.MachineLearningModel +.. autoclass:: tamr_unify_client.base_model.MachineLearningModel :members: Operations diff --git a/tamr_unify_client/models/machine_learning_model.py b/tamr_unify_client/base_model.py similarity index 100% rename from tamr_unify_client/models/machine_learning_model.py rename to tamr_unify_client/base_model.py diff --git a/tamr_unify_client/project/categorization.py b/tamr_unify_client/project/categorization.py index 897f71de..0cf1b12a 100644 --- a/tamr_unify_client/project/categorization.py +++ b/tamr_unify_client/project/categorization.py @@ -1,4 +1,4 @@ -from tamr_unify_client.models.machine_learning_model import MachineLearningModel +from tamr_unify_client.base_model import MachineLearningModel from tamr_unify_client.project.resource import Project from tamr_unify_client.taxonomy.resource import Taxonomy @@ -11,7 +11,7 @@ def model(self): Learns from verified labels and predicts categorization labels for unlabeled records. :returns: The machine learning model for categorization. - :rtype: :class:`~tamr_unify_client.models.machine_learning_model.MachineLearningModel` + :rtype: :class:`~tamr_unify_client.base_model.MachineLearningModel` """ alias = self.api_path + "/categorizations/model" return MachineLearningModel(self.client, None, alias) diff --git a/tamr_unify_client/project/mastering.py b/tamr_unify_client/project/mastering.py index 74f13da4..01c76a94 100644 --- a/tamr_unify_client/project/mastering.py +++ b/tamr_unify_client/project/mastering.py @@ -1,6 +1,6 @@ +from tamr_unify_client.base_model import MachineLearningModel from tamr_unify_client.dataset.resource import Dataset from tamr_unify_client.models.binning_model import BinningModel -from tamr_unify_client.models.machine_learning_model import MachineLearningModel from tamr_unify_client.project.cluster_configuration import ( PublishedClustersConfiguration, ) @@ -28,12 +28,12 @@ def pair_matching_model(self): """Machine learning model for pair-matching for this Mastering project. Learns from verified labels and predicts categorization labels for unlabeled pairs. - Calling :func:`~tamr_unify_client.models.machine_learning_model.MachineLearningModel.predict` + Calling :func:`~tamr_unify_client.base_model.MachineLearningModel.predict` from this dataset will produce new (unpublished) clusters. These clusters are displayed on the "Clusters" page in the Unify UI. :returns: The machine learning model for pair-matching. - :rtype: :class:`~tamr_unify_client.models.machine_learning_model.MachineLearningModel` + :rtype: :class:`~tamr_unify_client.base_model.MachineLearningModel` """ alias = self.api_path + "/recordPairsWithPredictions/model" return MachineLearningModel(self.client, None, alias) From 23f123798baca929c120bd7a62b3d527e0a0181f Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 26 Jul 2019 13:27:11 -0400 Subject: [PATCH 100/632] create mastering directory --- docs/developer-interface.rst | 8 ++++---- tamr_unify_client/mastering/__init__.py | 0 .../{models => mastering}/binning_model.py | 0 .../{project => mastering}/cluster_configuration.py | 0 .../{project => mastering}/estimated_pair_counts.py | 2 +- .../{project/mastering.py => mastering/project.py} | 12 ++++++------ tamr_unify_client/project/resource.py | 6 +++--- tests/unit/test_pair_counts.py | 4 ++-- tests/unit/test_published_clusters.py | 2 +- 9 files changed, 17 insertions(+), 17 deletions(-) create mode 100644 tamr_unify_client/mastering/__init__.py rename tamr_unify_client/{models => mastering}/binning_model.py (100%) rename tamr_unify_client/{project => mastering}/cluster_configuration.py (100%) rename tamr_unify_client/{project => mastering}/estimated_pair_counts.py (97%) rename tamr_unify_client/{project/mastering.py => mastering/project.py} (94%) diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index 7e3338d8..e7efff32 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -90,7 +90,7 @@ Attribute Configurations Estimated Pair Counts --------------------- -.. autoclass:: tamr_unify_client.project.estimated_pair_counts.EstimatedPairCounts +.. autoclass:: tamr_unify_client.mastering.estimated_pair_counts.EstimatedPairCounts :members: Machine Learning Models @@ -119,7 +119,7 @@ Project ---- -.. autoclass:: tamr_unify_client.project.mastering.MasteringProject +.. autoclass:: tamr_unify_client.mastering.project.MasteringProject :members: ---- @@ -129,12 +129,12 @@ Project ---- -.. autoclass:: tamr_unify_client.project.estimated_pair_counts.EstimatedPairCounts +.. autoclass:: tamr_unify_client.mastering.estimated_pair_counts.EstimatedPairCounts :members: ---- -.. autoclass:: tamr_unify_client.project.cluster_configuration.PublishedClustersConfiguration +.. autoclass:: tamr_unify_client.mastering.cluster_configuration.PublishedClustersConfiguration :members: Projects diff --git a/tamr_unify_client/mastering/__init__.py b/tamr_unify_client/mastering/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tamr_unify_client/models/binning_model.py b/tamr_unify_client/mastering/binning_model.py similarity index 100% rename from tamr_unify_client/models/binning_model.py rename to tamr_unify_client/mastering/binning_model.py diff --git a/tamr_unify_client/project/cluster_configuration.py b/tamr_unify_client/mastering/cluster_configuration.py similarity index 100% rename from tamr_unify_client/project/cluster_configuration.py rename to tamr_unify_client/mastering/cluster_configuration.py diff --git a/tamr_unify_client/project/estimated_pair_counts.py b/tamr_unify_client/mastering/estimated_pair_counts.py similarity index 97% rename from tamr_unify_client/project/estimated_pair_counts.py rename to tamr_unify_client/mastering/estimated_pair_counts.py index 1483ccb6..2eab3ca5 100644 --- a/tamr_unify_client/project/estimated_pair_counts.py +++ b/tamr_unify_client/mastering/estimated_pair_counts.py @@ -55,7 +55,7 @@ def refresh(self, **options): """Updates the estimated pair counts if needed. The pair count estimates are updated on the server; you will need to call - :func:`~tamr_unify_client.project.mastering.MasteringProject.estimate_pairs` + :func:`~tamr_unify_client.mastering.project.MasteringProject.estimate_pairs` to retrieve the updated estimate. :param ``**options``: Options passed to underlying :class:`~tamr_unify_client.operation.Operation` . diff --git a/tamr_unify_client/project/mastering.py b/tamr_unify_client/mastering/project.py similarity index 94% rename from tamr_unify_client/project/mastering.py rename to tamr_unify_client/mastering/project.py index 01c76a94..97c2ce78 100644 --- a/tamr_unify_client/project/mastering.py +++ b/tamr_unify_client/mastering/project.py @@ -1,10 +1,10 @@ from tamr_unify_client.base_model import MachineLearningModel from tamr_unify_client.dataset.resource import Dataset -from tamr_unify_client.models.binning_model import BinningModel -from tamr_unify_client.project.cluster_configuration import ( +from tamr_unify_client.mastering.binning_model import BinningModel +from tamr_unify_client.mastering.cluster_configuration import ( PublishedClustersConfiguration, ) -from tamr_unify_client.project.estimated_pair_counts import EstimatedPairCounts +from tamr_unify_client.mastering.estimated_pair_counts import EstimatedPairCounts from tamr_unify_client.project.resource import Project @@ -92,7 +92,7 @@ def published_clusters_configuration(self): """Retrieves published clusters configuration for this project. :returns: The published clusters configuration - :rtype: :class:`~tamr_unify_client.project.cluster_configuration.PublishedClustersConfiguration` + :rtype: :class:`~tamr_unify_client.mastering.cluster_configuration.PublishedClustersConfiguration` """ alias = self.api_path + "/publishedClustersConfiguration" resource_json = self.client.get(alias).successful().json() @@ -138,7 +138,7 @@ def estimate_pairs(self): """Returns pair estimate information for a mastering project :return: Pairs Estimate information. - :rtype: :class:`~tamr_unify_client.project.estimated_pair_counts.EstimatedPairCounts` + :rtype: :class:`~tamr_unify_client.mastering.estimated_pair_counts.EstimatedPairCounts` """ alias = self.api_path + "/estimatedPairCounts" estimate_json = self.client.get(alias).successful().json() @@ -178,7 +178,7 @@ def binning_model(self): Binning model for this project. :return: Binning model for this project. - :rtype: :class:`~tamr_unify_client.models.binning_model.BinningModel` + :rtype: :class:`~tamr_unify_client.mastering.binning_model.BinningModel` """ alias = self.api_path + "/binningModel" diff --git a/tamr_unify_client/project/resource.py b/tamr_unify_client/project/resource.py index 79b02be6..30bd344a 100644 --- a/tamr_unify_client/project/resource.py +++ b/tamr_unify_client/project/resource.py @@ -74,13 +74,13 @@ def as_categorization(self): return CategorizationProject(self.client, self._data, self.api_path) def as_mastering(self): - """Convert this project to a :class:`~tamr_unify_client.project.mastering.MasteringProject` + """Convert this project to a :class:`~tamr_unify_client.mastering.project.MasteringProject` :return: This project. - :rtype: :class:`~tamr_unify_client.project.mastering.MasteringProject` + :rtype: :class:`~tamr_unify_client.mastering.project.MasteringProject` :raises TypeError: If the :attr:`~tamr_unify_client.project.resource.Project.type` of this project is not ``"DEDUP"`` """ - from tamr_unify_client.project.mastering import MasteringProject + from tamr_unify_client.mastering.project import MasteringProject if self.type != "DEDUP": raise TypeError( diff --git a/tests/unit/test_pair_counts.py b/tests/unit/test_pair_counts.py index 4bbedcb9..68e469c0 100644 --- a/tests/unit/test_pair_counts.py +++ b/tests/unit/test_pair_counts.py @@ -4,9 +4,9 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth +from tamr_unify_client.mastering.estimated_pair_counts import EstimatedPairCounts +from tamr_unify_client.mastering.project import MasteringProject from tamr_unify_client.operation import Operation -from tamr_unify_client.project.estimated_pair_counts import EstimatedPairCounts -from tamr_unify_client.project.mastering import MasteringProject class TestPairCounts(TestCase): diff --git a/tests/unit/test_published_clusters.py b/tests/unit/test_published_clusters.py index 0d1c6816..7e9f8985 100644 --- a/tests/unit/test_published_clusters.py +++ b/tests/unit/test_published_clusters.py @@ -4,7 +4,7 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth -from tamr_unify_client.project.cluster_configuration import ( +from tamr_unify_client.mastering.cluster_configuration import ( PublishedClustersConfiguration, ) from tamr_unify_client.project.resource import Project From 94a8d91733e3158b8030ac73a53bd14ae24a5801 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 26 Jul 2019 13:45:15 -0400 Subject: [PATCH 101/632] create categorization directory --- docs/developer-interface.rst | 8 ++++---- .../{category => categorization}/__init__.py | 0 .../category}/__init__.py | 0 .../{ => categorization}/category/collection.py | 14 +++++++------- .../{ => categorization}/category/resource.py | 2 +- .../project.py} | 12 ++++++------ .../resource.py => categorization/taxonomy.py} | 4 ++-- tamr_unify_client/project/resource.py | 6 +++--- tests/unit/test_category.py | 2 +- tests/unit/test_taxonomy.py | 6 +++--- 10 files changed, 27 insertions(+), 27 deletions(-) rename tamr_unify_client/{category => categorization}/__init__.py (100%) rename tamr_unify_client/{taxonomy => categorization/category}/__init__.py (100%) rename tamr_unify_client/{ => categorization}/category/collection.py (85%) rename tamr_unify_client/{ => categorization}/category/resource.py (94%) rename tamr_unify_client/{project/categorization.py => categorization/project.py} (75%) rename tamr_unify_client/{taxonomy/resource.py => categorization/taxonomy.py} (81%) diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index e7efff32..f43b78f6 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -16,13 +16,13 @@ Client Category -------- -.. autoclass:: tamr_unify_client.category.resource.Category +.. autoclass:: tamr_unify_client.categorization.category.resource.Category :members: Categories ---------- -.. autoclass:: tamr_unify_client.category.collection.CategoryCollection +.. autoclass:: tamr_unify_client.categorization.category.collection.CategoryCollection :members: Dataset @@ -114,7 +114,7 @@ Project ---- -.. autoclass:: tamr_unify_client.project.categorization.CategorizationProject +.. autoclass:: tamr_unify_client.categorization.project.CategorizationProject :members: ---- @@ -146,5 +146,5 @@ Projects Taxonomy -------- -.. autoclass:: tamr_unify_client.taxonomy.resource.Taxonomy +.. autoclass:: tamr_unify_client.categorization.taxonomy.Taxonomy :members: \ No newline at end of file diff --git a/tamr_unify_client/category/__init__.py b/tamr_unify_client/categorization/__init__.py similarity index 100% rename from tamr_unify_client/category/__init__.py rename to tamr_unify_client/categorization/__init__.py diff --git a/tamr_unify_client/taxonomy/__init__.py b/tamr_unify_client/categorization/category/__init__.py similarity index 100% rename from tamr_unify_client/taxonomy/__init__.py rename to tamr_unify_client/categorization/category/__init__.py diff --git a/tamr_unify_client/category/collection.py b/tamr_unify_client/categorization/category/collection.py similarity index 85% rename from tamr_unify_client/category/collection.py rename to tamr_unify_client/categorization/category/collection.py index 7c850417..93f3fd86 100644 --- a/tamr_unify_client/category/collection.py +++ b/tamr_unify_client/categorization/category/collection.py @@ -1,11 +1,11 @@ import json from tamr_unify_client.base_collection import BaseCollection -from tamr_unify_client.category.resource import Category +from tamr_unify_client.categorization.category.resource import Category class CategoryCollection(BaseCollection): - """Collection of :class:`~tamr_unify_client.category.resource.Category` s. + """Collection of :class:`~tamr_unify_client.categorization.category.resource.Category` s. :param client: Client for API call delegation. :type client: :class:`~tamr_unify_client.Client` @@ -23,7 +23,7 @@ def by_resource_id(self, resource_id): :param resource_id: The resource ID. E.g. ``"1"`` :type resource_id: str :returns: The specified category. - :rtype: :class:`~tamr_unify_client.category.resource.Category` + :rtype: :class:`~tamr_unify_client.categorization.category.resource.Category` """ return super().by_resource_id(self.api_path, resource_id) @@ -33,7 +33,7 @@ def by_relative_id(self, relative_id): :param relative_id: The relative ID. E.g. ``"projects/1/categories/1"`` :type relative_id: str :returns: The specified category. - :rtype: :class:`~tamr_unify_client.category.resource.Category` + :rtype: :class:`~tamr_unify_client.categorization.category.resource.Category` """ return super().by_relative_id(Category, relative_id) @@ -46,7 +46,7 @@ def by_external_id(self, external_id): :param external_id: The external ID. :type external_id: str :returns: The specified category, if found. - :rtype: :class:`~tamr_unify_client.category.resource.Category` + :rtype: :class:`~tamr_unify_client.categorization.category.resource.Category` :raises KeyError: If no category with the specified external_id is found :raises LookupError: If multiple categories with the specified external_id are found """ @@ -57,7 +57,7 @@ def stream(self): over this collection. :returns: Stream of categories. - :rtype: Python generator yielding :class:`~tamr_unify_client.category.resource.Category` + :rtype: Python generator yielding :class:`~tamr_unify_client.categorization.category.resource.Category` Usage: >>> for category in collection.stream(): # explicit @@ -74,7 +74,7 @@ def create(self, creation_spec): `Public Docs for Creating a Category `_. :type creation_spec: dict :return: The newly created category. - :rtype: :class:`~tamr_unify_client.category.resource.Category` + :rtype: :class:`~tamr_unify_client.categorization.category.resource.Category` """ resource_json = ( self.client.post(self.api_path, json=creation_spec).successful().json() diff --git a/tamr_unify_client/category/resource.py b/tamr_unify_client/categorization/category/resource.py similarity index 94% rename from tamr_unify_client/category/resource.py rename to tamr_unify_client/categorization/category/resource.py index 7fe02d3d..a6935198 100644 --- a/tamr_unify_client/category/resource.py +++ b/tamr_unify_client/categorization/category/resource.py @@ -27,7 +27,7 @@ def parent(self): """Gets the parent Category of this one, or None if it is a tier 1 category :returns: The parent Category or None - :rtype: :class:`~tamr_unify_client.category.resource.Category` + :rtype: :class:`~tamr_unify_client.categorization.category.resource.Category` """ parent = self._data.get("parent") if parent: diff --git a/tamr_unify_client/project/categorization.py b/tamr_unify_client/categorization/project.py similarity index 75% rename from tamr_unify_client/project/categorization.py rename to tamr_unify_client/categorization/project.py index 0cf1b12a..55f8533b 100644 --- a/tamr_unify_client/project/categorization.py +++ b/tamr_unify_client/categorization/project.py @@ -1,6 +1,6 @@ from tamr_unify_client.base_model import MachineLearningModel +from tamr_unify_client.categorization.taxonomy import Taxonomy from tamr_unify_client.project.resource import Project -from tamr_unify_client.taxonomy.resource import Taxonomy class CategorizationProject(Project): @@ -17,26 +17,26 @@ def model(self): return MachineLearningModel(self.client, None, alias) def create_taxonomy(self, creation_spec): - """Creates a :class:`~tamr_unify_client.taxonomy.resource.Taxonomy` for this project. + """Creates a :class:`~tamr_unify_client.categorization.taxonomy.Taxonomy` for this project. A taxonomy cannot already be associated with this project. :param creation_spec: The creation specification for the taxonomy, which can include name. :type creation_spec: dict :returns: The new Taxonomy - :rtype: :class:`~tamr_unify_client.taxonomy.resource.Taxonomy` + :rtype: :class:`~tamr_unify_client.categorization.taxonomy.Taxonomy` """ alias = self.api_path + "/taxonomy" resource_json = self.client.post(alias, json=creation_spec).successful().json() return Taxonomy.from_json(self.client, resource_json, alias) def taxonomy(self): - """Retrieves the :class:`~tamr_unify_client.taxonomy.resource.Taxonomy` associated with this project. + """Retrieves the :class:`~tamr_unify_client.categorization.taxonomy.Taxonomy` associated with this project. If a taxonomy is not already associated with this project, - call :func:`~tamr_unify_client.project.categorization.CategorizationProject.create_taxonomy` first. + call :func:`~tamr_unify_client.categorization.project.CategorizationProject.create_taxonomy` first. :returns: The project's Taxonomy - :rtype: :class:`~tamr_unify_client.taxonomy.resource.Taxonomy` + :rtype: :class:`~tamr_unify_client.categorization.taxonomy.Taxonomy` """ alias = self.api_path + "/taxonomy" resource_json = self.client.get(alias).successful().json() diff --git a/tamr_unify_client/taxonomy/resource.py b/tamr_unify_client/categorization/taxonomy.py similarity index 81% rename from tamr_unify_client/taxonomy/resource.py rename to tamr_unify_client/categorization/taxonomy.py index faccb59f..563cbf95 100644 --- a/tamr_unify_client/taxonomy/resource.py +++ b/tamr_unify_client/categorization/taxonomy.py @@ -1,5 +1,5 @@ from tamr_unify_client.base_resource import BaseResource -from tamr_unify_client.category.collection import CategoryCollection +from tamr_unify_client.categorization.category.collection import CategoryCollection class Taxonomy(BaseResource): @@ -18,7 +18,7 @@ def categories(self): """Retrieves the categories of this taxonomy. :returns: A collection of the taxonomy categories. - :rtype: :class:`~tamr_unify_client.category.collection.CategoryCollection` + :rtype: :class:`~tamr_unify_client.categorization.category.collection.CategoryCollection` """ alias = self.api_path + "/categories" return CategoryCollection(self.client, alias) diff --git a/tamr_unify_client/project/resource.py b/tamr_unify_client/project/resource.py index 30bd344a..0e2e682f 100644 --- a/tamr_unify_client/project/resource.py +++ b/tamr_unify_client/project/resource.py @@ -59,13 +59,13 @@ def unified_dataset(self): return Dataset.from_json(self.client, resource_json, alias) def as_categorization(self): - """Convert this project to a :class:`~tamr_unify_client.project.categorization.CategorizationProject` + """Convert this project to a :class:`~tamr_unify_client.categorization.project.CategorizationProject` :return: This project. - :rtype: :class:`~tamr_unify_client.project.categorization.CategorizationProject` + :rtype: :class:`~tamr_unify_client.categorization.project.CategorizationProject` :raises TypeError: If the :attr:`~tamr_unify_client.project.resource.Project.type` of this project is not ``"CATEGORIZATION"`` """ - from tamr_unify_client.project.categorization import CategorizationProject + from tamr_unify_client.categorization.project import CategorizationProject if self.type != "CATEGORIZATION": raise TypeError( diff --git a/tests/unit/test_category.py b/tests/unit/test_category.py index 016b19c4..76a6884e 100644 --- a/tests/unit/test_category.py +++ b/tests/unit/test_category.py @@ -4,7 +4,7 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth -from tamr_unify_client.category.resource import Category +from tamr_unify_client.categorization.category.resource import Category class TestCategory(TestCase): diff --git a/tests/unit/test_taxonomy.py b/tests/unit/test_taxonomy.py index 944190a8..6eac948a 100644 --- a/tests/unit/test_taxonomy.py +++ b/tests/unit/test_taxonomy.py @@ -6,9 +6,9 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth -from tamr_unify_client.category.collection import CategoryCollection -from tamr_unify_client.category.resource import Category -from tamr_unify_client.taxonomy.resource import Taxonomy +from tamr_unify_client.categorization.category.collection import CategoryCollection +from tamr_unify_client.categorization.category.resource import Category +from tamr_unify_client.categorization.taxonomy import Taxonomy class TestTaxonomy(TestCase): From adce05606562696d48ae333531ce5d727bafb3e3 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 26 Jul 2019 13:49:03 -0400 Subject: [PATCH 102/632] remove models directory --- tamr_unify_client/models/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tamr_unify_client/models/__init__.py diff --git a/tamr_unify_client/models/__init__.py b/tamr_unify_client/models/__init__.py deleted file mode 100644 index e69de29b..00000000 From 17c3ec3184fa95d9bea71d0f0195ccf46d5e5801 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 26 Jul 2019 14:23:04 -0400 Subject: [PATCH 103/632] docs redesign and changelog --- CHANGELOG.md | 7 ++ docs/developer-interface.rst | 159 +++++++++++++++++++++-------------- 2 files changed, 104 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e43168a1..47a78cd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ - `AttributeType` no longer inherits from `BaseResource` (no API path), removing its `from_json` method and `relative_id` property - The type of `AttributeType`'s `attributes` property is now a `list` of `SubAttribute`s, which are identical to `Attribute`s except they lack an API path - The `Dataset` function `update_records` has been renamed `_update_records` as the convenience functions `upsert_records` and `delete_records` now exist. + - All files have been refactored: + * The `models` directory has been deleted, everything previously in it has been moved directly into the base directory + * `DatasetProfile` and `DatasetStatus` have been moved into the `dataset` directory + * `machine_learning_model.py` has been renamed `base_model.py` + * Attribute configurations have been moved to a subdirectory within `project` + * A `mastering` directory has been created with all mastering specific entities + * A `categorization` directory has been created with all categorization specific entities, including a `category` subdirectory **NEW FEATURES** - [#174](https://github.com/Datatamer/unify-client-python/issues/174) Get and create taxonomy categories diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index f43b78f6..53999a12 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -13,138 +13,173 @@ Client .. autoclass:: tamr_unify_client.Client :members: +Attribute +--------- + +Attribute +^^^^^^^^^ + +.. autoclass:: tamr_unify_client.attribute.resource.Attribute + :members: + +Attribute Collection +^^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: tamr_unify_client.attribute.collection.AttributeCollection + :members: + +Attribute Type +^^^^^^^^^^^^^^ + +.. autoclass:: tamr_unify_client.attribute.type.AttributeType + :members: + +SubAttribute +^^^^^^^^^^^^ +.. autoclass:: tamr_unify_client.attribute.subattribute.SubAttribute + :members: + +Categorization +-------------- + +Categorization Project +^^^^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: tamr_unify_client.categorization.project.CategorizationProject + :members: + +Categories +^^^^^^^^^^ + Category --------- +"""""""" .. autoclass:: tamr_unify_client.categorization.category.resource.Category :members: -Categories ----------- +Category Collection +""""""""""""""""""" .. autoclass:: tamr_unify_client.categorization.category.collection.CategoryCollection :members: +Taxonomy +^^^^^^^^ + +.. autoclass:: tamr_unify_client.categorization.taxonomy.Taxonomy + :members: + Dataset ------- +Dataset +^^^^^^^ + .. autoclass:: tamr_unify_client.dataset.resource.Dataset :members: +Dataset Collection +^^^^^^^^^^^^^^^^^^ + +.. autoclass:: tamr_unify_client.dataset.collection.DatasetCollection + :members: + Dataset Profile ---------------- +^^^^^^^^^^^^^^^ .. autoclass:: tamr_unify_client.dataset.profile.DatasetProfile :members: Dataset Status --------------- +^^^^^^^^^^^^^^ .. autoclass:: tamr_unify_client.dataset.status.DatasetStatus :members: Dataset Usage -------------- +^^^^^^^^^^^^^ .. autoclass:: tamr_unify_client.dataset.usage.DatasetUsage :members: ----- +Dataset Use +^^^^^^^^^^^ .. autoclass:: tamr_unify_client.dataset.use.DatasetUse :members: -Datasets --------- +Machine Learning Model +---------------------- -.. autoclass:: tamr_unify_client.dataset.collection.DatasetCollection +.. autoclass:: tamr_unify_client.base_model.MachineLearningModel :members: -Attribute +Mastering --------- -.. autoclass:: tamr_unify_client.attribute.resource.Attribute - -.. autoclass:: tamr_unify_client.attribute.subattribute.SubAttribute - -Attribute Type --------------- - -.. autoclass:: tamr_unify_client.attribute.type.AttributeType +Binning Model +^^^^^^^^^^^^^ -Attributes ----------- - -.. autoclass:: tamr_unify_client.attribute.collection.AttributeCollection - -Attribute Configuration ------------------------ - -.. autoclass:: tamr_unify_client.project.attribute_configuration.resource.AttributeConfiguration - -Attribute Configurations ------------------------- - -.. autoclass:: tamr_unify_client.project.attribute_configuration.collection.AttributeConfigurationCollection +.. autoclass:: tamr_unify_client.mastering.binning_model.BinningModel + :members: Estimated Pair Counts ---------------------- +^^^^^^^^^^^^^^^^^^^^^ .. autoclass:: tamr_unify_client.mastering.estimated_pair_counts.EstimatedPairCounts :members: -Machine Learning Models ------------------------ +Mastering Project +^^^^^^^^^^^^^^^^^ -.. autoclass:: tamr_unify_client.base_model.MachineLearningModel +.. autoclass:: tamr_unify_client.mastering.project.MasteringProject :members: -Operations ----------- +Published Clusters Configuration +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -.. autoclass:: tamr_unify_client.operation.Operation +.. autoclass:: tamr_unify_client.mastering.cluster_configuration.PublishedClustersConfiguration :members: +Operation +--------- -Project -------- - -.. autoclass:: tamr_unify_client.project.resource.Project +.. autoclass:: tamr_unify_client.operation.Operation :members: ----- - -.. autoclass:: tamr_unify_client.categorization.project.CategorizationProject - :members: ----- +Project +------- -.. autoclass:: tamr_unify_client.mastering.project.MasteringProject - :members: +Attribute Configurations +^^^^^^^^^^^^^^^^^^^^^^^^ ----- +Attribute Configuration +""""""""""""""""""""""" -.. autoclass:: tamr_unify_client.project.step.ProjectStep +.. autoclass:: tamr_unify_client.project.attribute_configuration.resource.AttributeConfiguration :members: ----- +Attribute Configuration Collection +"""""""""""""""""""""""""""""""""" -.. autoclass:: tamr_unify_client.mastering.estimated_pair_counts.EstimatedPairCounts +.. autoclass:: tamr_unify_client.project.attribute_configuration.collection.AttributeConfigurationCollection :members: ----- +Project +^^^^^^^ -.. autoclass:: tamr_unify_client.mastering.cluster_configuration.PublishedClustersConfiguration +.. autoclass:: tamr_unify_client.project.resource.Project :members: -Projects --------- +Project Collection +^^^^^^^^^^^^^^^^^^ .. autoclass:: tamr_unify_client.project.collection.ProjectCollection :members: -Taxonomy --------- +Project Step +^^^^^^^^^^^^ -.. autoclass:: tamr_unify_client.categorization.taxonomy.Taxonomy +.. autoclass:: tamr_unify_client.project.step.ProjectStep :members: \ No newline at end of file From 9808f70caff3584894bce3634ab9c497842e9adf Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Tue, 30 Jul 2019 11:56:02 -0400 Subject: [PATCH 104/632] Developer-interface edits. --- docs/developer-interface.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index 53999a12..1bc6ad37 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -48,8 +48,8 @@ Categorization Project .. autoclass:: tamr_unify_client.categorization.project.CategorizationProject :members: -Categories -^^^^^^^^^^ +Category +^^^^^^^^ Category """""""" @@ -151,8 +151,8 @@ Operation Project ------- -Attribute Configurations -^^^^^^^^^^^^^^^^^^^^^^^^ +Attribute Configuration +^^^^^^^^^^^^^^^^^^^^^^^ Attribute Configuration """"""""""""""""""""""" @@ -182,4 +182,4 @@ Project Step ^^^^^^^^^^^^ .. autoclass:: tamr_unify_client.project.step.ProjectStep - :members: \ No newline at end of file + :members: From 6d9d92333c2cf721a2df9fd92764eef86b3364d0 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 26 Jul 2019 16:54:25 -0400 Subject: [PATCH 105/632] creating published cluster entities --- .../mastering/published_cluster/__init__.py | 0 .../mastering/published_cluster/metric.py | 28 +++++++++++ .../mastering/published_cluster/resource.py | 32 +++++++++++++ .../mastering/published_cluster/version.py | 47 +++++++++++++++++++ 4 files changed, 107 insertions(+) create mode 100644 tamr_unify_client/mastering/published_cluster/__init__.py create mode 100644 tamr_unify_client/mastering/published_cluster/metric.py create mode 100644 tamr_unify_client/mastering/published_cluster/resource.py create mode 100644 tamr_unify_client/mastering/published_cluster/version.py diff --git a/tamr_unify_client/mastering/published_cluster/__init__.py b/tamr_unify_client/mastering/published_cluster/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tamr_unify_client/mastering/published_cluster/metric.py b/tamr_unify_client/mastering/published_cluster/metric.py new file mode 100644 index 00000000..e396d23c --- /dev/null +++ b/tamr_unify_client/mastering/published_cluster/metric.py @@ -0,0 +1,28 @@ +class Metric: + """ A metric for a published cluster. + + This is not a `BaseResource` because it does not have its own API endpoint. + + :param data: The JSON entity representing this cluster. + """ + + def __init__(self, data): + self._data = data + + @property + def name(self): + """:type: str""" + return self._data.get("metricName") + + @property + def value(self): + """:type: str""" + return self._data.get("metricValue") + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"name={self.name!r}, " + f"value={self.value!r})" + ) diff --git a/tamr_unify_client/mastering/published_cluster/resource.py b/tamr_unify_client/mastering/published_cluster/resource.py new file mode 100644 index 00000000..ee2a4193 --- /dev/null +++ b/tamr_unify_client/mastering/published_cluster/resource.py @@ -0,0 +1,32 @@ +from tamr_unify_client.mastering.published_cluster.version import ( + PublishedClusterVersion, +) + + +class PublishedCluster: + """A representation of a published cluster in a mastering project with version information. + + This is not a `BaseResource` because it does not have its own API endpoint. + + :param data: The JSON entity representing this cluster. + """ + + def __init__(self, data): + self._data = data + + @property + def id(self): + """:type: str""" + return self._data.get("id") + + @property + def versions(self): + """:type: list[:class:`~tamr_unify_client.mastering.published_cluster.version.PublishedClusterVersion`]""" + return [PublishedClusterVersion(v) for v in self._data.get("versions")] + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"id={self.id!r})" + ) diff --git a/tamr_unify_client/mastering/published_cluster/version.py b/tamr_unify_client/mastering/published_cluster/version.py new file mode 100644 index 00000000..7e037ec5 --- /dev/null +++ b/tamr_unify_client/mastering/published_cluster/version.py @@ -0,0 +1,47 @@ +from tamr_unify_client.mastering.published_cluster.metric import Metric + + +class PublishedClusterVersion: + """A version of a published cluster in a mastering project. + + This is not a `BaseResource` because it does not have its own API endpoint. + + :param data: The JSON entity representing this version. + """ + + def __init__(self, data): + self._data = data + + @property + def version(self): + """:type: str""" + return self._data.get("version") + + @property + def timestamp(self): + """:type: str""" + return self._data.get("timestamp") + + @property + def name(self): + """:type: str""" + return self._data.get("name") + + @property + def metrics(self): + """:type: list[:class:`~tamr_unify_client.mastering.published_cluster.metric.Metric`]""" + return [Metric(m) for m in self._data.get("metrics")] + + @property + def record_ids(self): + """:type: list[dict[str, str]]""" + return self._data.get("recordIds") + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"version={self.version!r}, " + f"timestamp={self.timestamp!r}, " + f"name={self.name!r})" + ) From 876dd0afe784957e90aa9b3122e1879f5953c381 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Mon, 29 Jul 2019 13:32:54 -0400 Subject: [PATCH 106/632] getting published cluster versions --- tamr_unify_client/mastering/project.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tamr_unify_client/mastering/project.py b/tamr_unify_client/mastering/project.py index 97c2ce78..f63d5a51 100644 --- a/tamr_unify_client/mastering/project.py +++ b/tamr_unify_client/mastering/project.py @@ -1,3 +1,5 @@ +import json + from tamr_unify_client.base_model import MachineLearningModel from tamr_unify_client.dataset.resource import Dataset from tamr_unify_client.mastering.binning_model import BinningModel @@ -5,6 +7,7 @@ PublishedClustersConfiguration, ) from tamr_unify_client.mastering.estimated_pair_counts import EstimatedPairCounts +from tamr_unify_client.mastering.published_cluster.resource import PublishedCluster from tamr_unify_client.project.resource import Project @@ -134,6 +137,23 @@ def published_cluster_stats(self): path = self.api_path + "/publishedClusterStats" return Dataset.from_json(self.client, dataset._data, path) + def published_cluster_versions(self, cluster_ids): + """Retrieves version information for the specified published clusters. + + :param cluster_ids: The persistent IDs of the clusters to get version information for. + :type cluster_ids: list[str] + :return: A stream of the published clusters. + :rtype: Python generator yielding :class:`~tamr_unify_client.mastering.published_cluster.resource.PublishedCluster` + """ + stringified_ids = "\n".join( + json.dumps(cluster_id) for cluster_id in cluster_ids + ) + url = self.api_path + "/publishedClusterVersions" + + with self.client.post(url, data=stringified_ids, stream=True) as response: + for line in response.iter_lines(): + yield PublishedCluster(json.loads(line)) + def estimate_pairs(self): """Returns pair estimate information for a mastering project From bf40f8352ae6fe4c8a6b025294487b7c7a35d293 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Mon, 29 Jul 2019 15:28:22 -0400 Subject: [PATCH 107/632] published cluster versions tests --- tests/unit/test_published_cluster_version.py | 182 +++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 tests/unit/test_published_cluster_version.py diff --git a/tests/unit/test_published_cluster_version.py b/tests/unit/test_published_cluster_version.py new file mode 100644 index 00000000..e0a105d6 --- /dev/null +++ b/tests/unit/test_published_cluster_version.py @@ -0,0 +1,182 @@ +from functools import partial +import json +from unittest import TestCase + +import responses + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth +from tamr_unify_client.mastering.published_cluster.metric import Metric +from tamr_unify_client.mastering.published_cluster.resource import PublishedCluster +from tamr_unify_client.mastering.published_cluster.version import ( + PublishedClusterVersion, +) +from tamr_unify_client.project.resource import Project + + +class PublishedClusterTest(TestCase): + def setUp(self): + auth = UsernamePasswordAuth("username", "password") + self.unify = Client(auth) + + def test_metric(self): + metric_json = {"metricName": "recordCount", "metricValue": "1"} + m = Metric(metric_json) + self.assertEqual(m.name, metric_json["metricName"]) + self.assertEqual(m.value, metric_json["metricValue"]) + + def test_cluster_version(self): + version_json = self._versions_json[0]["versions"][0] + version = PublishedClusterVersion(version_json) + + self.assertEqual(version.version, version_json["version"]) + self.assertEqual(version.timestamp, version_json["timestamp"]) + self.assertEqual(version.name, version_json["name"]) + self.assertEqual(version.record_ids, version_json["recordIds"]) + + metrics = [Metric(m) for m in version_json["metrics"]] + for actual, expected in zip(version.metrics, metrics): + self.assertEqual(actual.__repr__(), expected.__repr__()) + + def test_cluster(self): + cluster_json = self._versions_json[0] + cluster = PublishedCluster(cluster_json) + versions = cluster.versions + expected_versions = [ + PublishedClusterVersion(v) for v in cluster_json["versions"] + ] + + self.assertEqual(cluster.id, cluster_json["id"]) + self.assertEqual(len(versions), len(expected_versions)) + for actual, expected in zip(versions, expected_versions): + self.assertEqual(actual.__repr__(), expected.__repr__()) + + @responses.activate + def test_get_versions(self): + def create_callback(request, snoop): + snoop["payload"] = request.body + return 200, {}, "\n".join(json.dumps(c) for c in self._versions_json) + + p = Project.from_json(self.unify, self._project_json).as_mastering() + post_url = f"http://localhost:9100/api/versioned/v1/{p.api_path}/publishedClusterVersions" + snoop = {} + responses.add_callback( + responses.POST, post_url, partial(create_callback, snoop=snoop) + ) + + clusters = list(p.published_cluster_versions(self._cluster_ids)) + expected_clusters = [PublishedCluster(c) for c in self._versions_json] + + self.assertEqual( + snoop["payload"], "\n".join([json.dumps(i) for i in self._cluster_ids]) + ) + self.assertEqual(len(clusters), len(expected_clusters)) + for actual, expected in zip(clusters, expected_clusters): + self.assertEqual(actual.__repr__(), expected.__repr__()) + self.assertEqual(len(actual.versions), len(expected.versions)) + + _project_json = { + "id": "unify://unified-data/v1/projects/1", + "name": "Test Project", + "description": "Mastering Project", + "type": "DEDUP", + "unifiedDatasetName": "", + "created": { + "username": "admin", + "time": "2019-07-12T13:08:17.440Z", + "version": "401", + }, + "lastModified": { + "username": "admin", + "time": "2019-07-12T13:08:17.534Z", + "version": "402", + }, + "relativeId": "projects/1", + "externalId": "904bf89e-74ba-45c5-8b4a-5ff913728f66", + } + + _cluster_ids = [ + "055908e7-2144-3f46-ba21-4c2e58816228", + "ca68d64b-755e-32b7-a785-5f9b1f51e420", + ] + + _versions_json = [ + { + "id": "055908e7-2144-3f46-ba21-4c2e58816228", + "versions": [ + { + "version": 324, + "timestamp": "2019-07-17T15:48:40.171Z", + "name": "cluster 1", + "metrics": [ + {"metricName": "recordCount", "metricValue": "2"}, + {"metricName": "totalSpend", "metricValue": "0.0"}, + {"metricName": "verifiedRecordCount", "metricValue": "0"}, + { + "metricName": "averageLinkage", + "metricValue": "0.7626373626373626", + }, + ], + "recordIds": [ + { + "entityId": "6084737977926081128", + "originSourceId": "dataset_name", + "originEntityId": "82049", + }, + { + "entityId": "-3832930559140320929", + "originSourceId": "dataset_name", + "originEntityId": "80455", + }, + ], + }, + { + "version": 323, + "timestamp": "2019-07-15T15:48:40.171Z", + "name": "cluster 1", + "metrics": [ + {"metricName": "recordCount", "metricValue": "1"}, + {"metricName": "totalSpend", "metricValue": "0.0"}, + {"metricName": "verifiedRecordCount", "metricValue": "0"}, + { + "metricName": "averageLinkage", + "metricValue": "0.7626373626373626", + }, + ], + "recordIds": [ + { + "entityId": "6084737977926081128", + "originSourceId": "dataset_name", + "originEntityId": "82049", + } + ], + }, + ], + }, + { + "id": "ca68d64b-755e-32b7-a785-5f9b1f51e420", + "versions": [ + { + "version": 324, + "timestamp": "2019-07-17T15:48:40.171Z", + "name": "cluster 2", + "metrics": [ + {"metricName": "recordCount", "metricValue": "1"}, + {"metricName": "totalSpend", "metricValue": "0.0"}, + {"metricName": "verifiedRecordCount", "metricValue": "0"}, + { + "metricName": "averageLinkage", + "metricValue": "0.7582417582417584", + }, + ], + "recordIds": [ + { + "entityId": "-4650342988873587155", + "originSourceId": "dataset_name", + "originEntityId": "63730", + } + ], + } + ], + }, + ] From 302d43ee3d67b47a7907a25d75d50bf867d8e1ce Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 30 Jul 2019 10:19:51 -0400 Subject: [PATCH 108/632] move cluster configuration --- tamr_unify_client/mastering/project.py | 6 +++--- .../configuration.py} | 0 tests/unit/test_published_clusters.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename tamr_unify_client/mastering/{cluster_configuration.py => published_cluster/configuration.py} (100%) diff --git a/tamr_unify_client/mastering/project.py b/tamr_unify_client/mastering/project.py index f63d5a51..71736a37 100644 --- a/tamr_unify_client/mastering/project.py +++ b/tamr_unify_client/mastering/project.py @@ -3,10 +3,10 @@ from tamr_unify_client.base_model import MachineLearningModel from tamr_unify_client.dataset.resource import Dataset from tamr_unify_client.mastering.binning_model import BinningModel -from tamr_unify_client.mastering.cluster_configuration import ( +from tamr_unify_client.mastering.estimated_pair_counts import EstimatedPairCounts +from tamr_unify_client.mastering.published_cluster.configuration import ( PublishedClustersConfiguration, ) -from tamr_unify_client.mastering.estimated_pair_counts import EstimatedPairCounts from tamr_unify_client.mastering.published_cluster.resource import PublishedCluster from tamr_unify_client.project.resource import Project @@ -95,7 +95,7 @@ def published_clusters_configuration(self): """Retrieves published clusters configuration for this project. :returns: The published clusters configuration - :rtype: :class:`~tamr_unify_client.mastering.cluster_configuration.PublishedClustersConfiguration` + :rtype: :class:`~tamr_unify_client.mastering.published_cluster.configuration.PublishedClustersConfiguration` """ alias = self.api_path + "/publishedClustersConfiguration" resource_json = self.client.get(alias).successful().json() diff --git a/tamr_unify_client/mastering/cluster_configuration.py b/tamr_unify_client/mastering/published_cluster/configuration.py similarity index 100% rename from tamr_unify_client/mastering/cluster_configuration.py rename to tamr_unify_client/mastering/published_cluster/configuration.py diff --git a/tests/unit/test_published_clusters.py b/tests/unit/test_published_clusters.py index 7e9f8985..908d8c06 100644 --- a/tests/unit/test_published_clusters.py +++ b/tests/unit/test_published_clusters.py @@ -4,7 +4,7 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth -from tamr_unify_client.mastering.cluster_configuration import ( +from tamr_unify_client.mastering.published_cluster.configuration import ( PublishedClustersConfiguration, ) from tamr_unify_client.project.resource import Project From b6b89b1505d436dfcbfea9f901e215d0cb1713c0 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Mon, 29 Jul 2019 16:01:51 -0400 Subject: [PATCH 109/632] docs and changelog --- CHANGELOG.md | 1 + docs/developer-interface.rst | 26 +++++++++++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47a78cd0..c655dba3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - [#194](https://github.com/Datatamer/unify-client-python/issues/194) Allowing additional JSON parameters to be used for update of records - [#205](https://github.com/Datatamer/unify-client-python/issues/205) Update a dataset's records with records rather than record updates - Delete records from a dataset by providing records rather than record updates + - [#183](https://github.com/Datatamer/unify-client-python/issues/183) Retrieve versions of published clusters **BUG FIXES** - [#212](https://github.com/Datatamer/unify-client-python/issues/212) Sped up slow running tests diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index 1bc6ad37..bba2fa85 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -135,10 +135,30 @@ Mastering Project .. autoclass:: tamr_unify_client.mastering.project.MasteringProject :members: -Published Clusters Configuration -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Published Cluster +^^^^^^^^^^^^^^^^^ + +Metric +"""""" +.. autoclass:: tamr_unify_client.mastering.published_cluster.metric.Metric + :members: + + +Published Cluster +""""""""""""""""" +.. autoclass:: tamr_unify_client.mastering.published_cluster.resource.PublishedCluster + :members: + +Published Cluster Configuration +""""""""""""""""""""""""""""""" + +.. autoclass:: tamr_unify_client.mastering.published_cluster.configuration.PublishedClustersConfiguration + :members: + +Published Cluster Version +""""""""""""""""""""""""" -.. autoclass:: tamr_unify_client.mastering.cluster_configuration.PublishedClustersConfiguration +.. autoclass:: tamr_unify_client.mastering.published_cluster.version.PublishedClusterVersion :members: Operation From 0396847d62e33ee55b93151f44ad2fb14626f611 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 1 Aug 2019 15:42:26 -0400 Subject: [PATCH 110/632] delete all records from a dataset --- CHANGELOG.md | 1 + tamr_unify_client/dataset/resource.py | 10 ++++++++++ tests/unit/test_dataset_records.py | 9 +++++++++ 3 files changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c655dba3..2ea81228 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - [#205](https://github.com/Datatamer/unify-client-python/issues/205) Update a dataset's records with records rather than record updates - Delete records from a dataset by providing records rather than record updates - [#183](https://github.com/Datatamer/unify-client-python/issues/183) Retrieve versions of published clusters + - [#220](https://github.com/Datatamer/unify-client-python/issues/220) Delete all records from a dataset **BUG FIXES** - [#212](https://github.com/Datatamer/unify-client-python/issues/212) Sped up slow running tests diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index efaab741..bb541ba7 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -123,6 +123,16 @@ def delete_records_by_id(self, record_ids): updates = ({"action": "DELETE", "recordId": rid} for rid in record_ids) return self._update_records(updates) + def delete_all_records(self): + """Removes all records from the dataset. + + :return: HTTP response from the server + :rtype: :class:`requests.Response` + """ + path = self.api_path + "/records" + response = self.client.delete(path).successful() + return response + def refresh(self, **options): """Brings dataset up-to-date if needed, taking whatever actions are required. diff --git a/tests/unit/test_dataset_records.py b/tests/unit/test_dataset_records.py index f6483c3e..d96e9533 100644 --- a/tests/unit/test_dataset_records.py +++ b/tests/unit/test_dataset_records.py @@ -140,6 +140,15 @@ def create_callback(request, snoop): self.assertEqual(response, self._response_json) self.assertEqual(snoop["payload"], TestDatasetRecords.stringify(deletes, False)) + @responses.activate + def test_delete_all(self): + responses.add(responses.GET, self._dataset_url, json={}) + dataset = self.unify.datasets.by_resource_id(self._dataset_id) + + responses.add(responses.DELETE, self._dataset_url + "/records", status=204) + response = dataset.delete_all_records() + self.assertEqual(response.status_code, 204) + @staticmethod def records_to_deletes(records): return [ From 55765f7e69f69c861fe625933891a9c7872d1adb Mon Sep 17 00:00:00 2001 From: Charlotte Moremen Date: Tue, 30 Jul 2019 16:43:45 -0400 Subject: [PATCH 111/632] schema mapping reviewed by J updated schema_mapping Thurs afternoon Friday morning updates fridaty afternoon oops brain time to go to SLEEP --- CHANGELOG.md | 1 + docs/developer-interface.rst | 16 ++ .../project/attribute_mapping/__init__.py | 0 .../project/attribute_mapping/collection.py | 57 ++++ .../project/attribute_mapping/resource.py | 81 ++++++ tamr_unify_client/project/resource.py | 13 + tests/unit/test_attribute_mapping.py | 51 ++++ .../unit/test_attribute_mapping_collection.py | 134 ++++++++++ tests/unit/test_project.py | 245 +++++++++++++++++- 9 files changed, 597 insertions(+), 1 deletion(-) create mode 100644 tamr_unify_client/project/attribute_mapping/__init__.py create mode 100644 tamr_unify_client/project/attribute_mapping/collection.py create mode 100644 tamr_unify_client/project/attribute_mapping/resource.py create mode 100644 tests/unit/test_attribute_mapping.py create mode 100644 tests/unit/test_attribute_mapping_collection.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c655dba3..91b1b4b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - [#202](https://github.com/Datatamer/unify-client-python/issues/202) Support for refreshing published cluster stats - [#194](https://github.com/Datatamer/unify-client-python/issues/194) Allowing additional JSON parameters to be used for update of records - [#205](https://github.com/Datatamer/unify-client-python/issues/205) Update a dataset's records with records rather than record updates + - [#111](https://github.com/Datatamer/unify-client-python/issues/111) support for schema mapping attributes - Delete records from a dataset by providing records rather than record updates - [#183](https://github.com/Datatamer/unify-client-python/issues/183) Retrieve versions of published clusters diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index bba2fa85..02c1aa14 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -186,6 +186,22 @@ Attribute Configuration Collection .. autoclass:: tamr_unify_client.project.attribute_configuration.collection.AttributeConfigurationCollection :members: + +Attribute Mapping +^^^^^^^^^^^^^^^^^ + +Attribute Mapping +""""""""""""""""" + +.. autoclass:: tamr_unify_client.project.attribute_mapping.resource.AttributeMapping + :members: + +Attribute Mapping Collection +"""""""""""""""""""""""""""" + +.. autoclass:: tamr_unify_client.project.attribute_mapping.collection.AttributeMappingCollection + :members: + Project ^^^^^^^ diff --git a/tamr_unify_client/project/attribute_mapping/__init__.py b/tamr_unify_client/project/attribute_mapping/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tamr_unify_client/project/attribute_mapping/collection.py b/tamr_unify_client/project/attribute_mapping/collection.py new file mode 100644 index 00000000..e28e57ed --- /dev/null +++ b/tamr_unify_client/project/attribute_mapping/collection.py @@ -0,0 +1,57 @@ +from tamr_unify_client.project.attribute_mapping.resource import AttributeMapping + + +class AttributeMappingCollection: + """Collection of :class:`~tamr_unify_client.project.attribute_mapping.resource.AttributeMapping` + :param map_url: API path used to access this collection. + :type api_path: str + :param client: Client for API call delegation. + :type client: :class:`~tamr_unify_client.Client` + """ + + def __init__(self, client, api_path): + self.api_path = api_path + self.client = client + + def stream(self): + """Stream items in this collection. + :returns: Stream of attribute mappings. + """ + all_maps = self.client.get(self.api_path).successful().json() + for mapping in all_maps: + yield AttributeMapping(mapping) + + def by_resource_id(self, resource_id): + """Retrieve an item in this collection by resource ID. + :param resource_id: The resource ID. + :type resource_id: str + :returns: The specified attribute mapping. + :rtype: :class:`~tamr_unify_client.project.attribute_mapping.resource.AttributeMapping` + """ + maps = self.stream() + for mapping in maps: + split_id = mapping.resource_id + if resource_id == split_id: + return mapping + raise LookupError("cannot locate mapping from resource ID") + + def by_relative_id(self, relative_id): + """Retrieve an item in this collection by relative ID. + :param relative_id: The relative ID. + :type relative_id: str + :returns: The specified attribute mapping. + :rtype: :class:`~tamr_unify_client.project.attribute_mapping.resource.AttributeMapping` + """ + resource_id = relative_id.split("attributeMappings/")[1] + return self.by_resource_id(resource_id) + + def create(self, creation_spec): + """Create an Attribute mapping in this collection + :param creation_spec: Attribute mapping creation specification should be formatted as specified in the + `Public Docs for adding an AttributeMapping `_. + :type creation_spec: dict[str, str] + :returns: The created Attribute mapping + :rtype: :class:`~tamr_unify_client.project.attribute_mapping.resource.AttributeMapping` + """ + data = self.client.post(self.api_path, json=creation_spec).successful().json() + return AttributeMapping(data) diff --git a/tamr_unify_client/project/attribute_mapping/resource.py b/tamr_unify_client/project/attribute_mapping/resource.py new file mode 100644 index 00000000..140be650 --- /dev/null +++ b/tamr_unify_client/project/attribute_mapping/resource.py @@ -0,0 +1,81 @@ +class AttributeMapping: + """see https://docs.tamr.com/reference#retrieve-projects-mappings + AttributeMapping and AttributeMappingCollection do not inherit from BaseResource and BaseCollection. + BC and BR require a specific URL for each individual attribute mapping + (ex: /projects/1/attributeMappings/1), but these types of URLs do not exist for attribute mappings + """ + + def __init__(self, data): + self._data = data + + @property + def id(self): + """:type: str""" + return self._data["id"] + + @property + def relative_id(self): + """:type: str""" + return self._data["relativeId"] + + @property + def input_attribute_id(self): + """:type: str""" + return self._data["inputAttributeId"] + + @property + def relative_input_attribute_id(self): + """:type: str""" + return self._data["relativeInputAttributeId"] + + @property + def input_dataset_name(self): + """:type: str""" + return self._data["inputDatasetName"] + + @property + def input_attribute_name(self): + """:type: str""" + return self._data["inputAttributeName"] + + @property + def unified_attribute_id(self): + """:type: str""" + return self._data["unifiedAttributeId"] + + @property + def relative_unified_attribute_id(self): + """:type: str""" + return self._data["relativeUnifiedAttributeId"] + + @property + def unified_dataset_name(self): + """:type: str""" + return self._data["unifiedDatasetName"] + + @property + def unified_attribute_name(self): + """:type: str""" + return self._data["unifiedAttributeName"] + + @property + def resource_id(self): + """:type: str""" + spliced = self.relative_id.split("attributeMappings/")[1] + return spliced + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"id={self.id!r}, " + f"relative_id={self.relative_id!r}, " + f"input_attribute_id={self.input_attribute_id!r}, " + f"relative_input_attribute_id={self.relative_input_attribute_id!r}, " + f"input_dataset_name={self.input_dataset_name!r}, " + f"input_attribute_name={self.input_attribute_name!r}, " + f"unified_attribute_id={self.unified_attribute_id!r}, " + f"relative_unified_attribute_id={self.relative_unified_attribute_id!r}, " + f"unified_dataset_name={self.unified_dataset_name!r}, " + f"unified_attribute_name={self.unified_attribute_name!r})" + ) diff --git a/tamr_unify_client/project/resource.py b/tamr_unify_client/project/resource.py index 0e2e682f..1923eb18 100644 --- a/tamr_unify_client/project/resource.py +++ b/tamr_unify_client/project/resource.py @@ -4,6 +4,9 @@ from tamr_unify_client.project.attribute_configuration.collection import ( AttributeConfigurationCollection, ) +from tamr_unify_client.project.attribute_mapping.collection import ( + AttributeMappingCollection, +) class Project(BaseResource): @@ -126,6 +129,16 @@ def attribute_configurations(self): info = AttributeConfigurationCollection(self.client, api_path=alias) return info + def attribute_mappings(self): + """Project's attribute's mappings. + + :returns: The attribute mappings of a project. + :rtype: :class:`~tamr_unify_client.project.attribute_mapping.collection.AttributeMappingCollection` + """ + alias = self.api_path + "/attributeMappings" + info = AttributeMappingCollection(self.client, alias) + return info + def __repr__(self): return ( f"{self.__class__.__module__}." diff --git a/tests/unit/test_attribute_mapping.py b/tests/unit/test_attribute_mapping.py new file mode 100644 index 00000000..35ac30c7 --- /dev/null +++ b/tests/unit/test_attribute_mapping.py @@ -0,0 +1,51 @@ +from unittest import TestCase + +from tamr_unify_client.project.attribute_mapping.resource import AttributeMapping + + +class TestAttributeMapping(TestCase): + def test_resource(self): + test = AttributeMapping(self.mappings_json) + + expected = self.mappings_json["relativeId"] + self.assertEqual(expected, test.relative_id) + + expected = self.mappings_json["id"] + self.assertEqual(expected, test.id) + + expected = self.mappings_json["inputAttributeId"] + self.assertEqual(expected, test.input_attribute_id) + + expected = self.mappings_json["relativeInputAttributeId"] + self.assertEqual(expected, test.relative_input_attribute_id) + + expected = self.mappings_json["inputDatasetName"] + self.assertEqual(expected, test.input_dataset_name) + + expected = self.mappings_json["inputAttributeName"] + self.assertEqual(expected, test.input_attribute_name) + + expected = self.mappings_json["unifiedAttributeId"] + self.assertEqual(expected, test.unified_attribute_id) + + expected = self.mappings_json["relativeUnifiedAttributeId"] + self.assertEqual(expected, test.relative_unified_attribute_id) + + expected = self.mappings_json["unifiedDatasetName"] + self.assertEqual(expected, test.unified_dataset_name) + + expected = self.mappings_json["unifiedAttributeName"] + self.assertEqual(expected, test.unified_attribute_name) + + mappings_json = { + "id": "unify://unified-data/v1/projects/4/attributeMappings/19629-12", + "relativeId": "projects/4/attributeMappings/19629-12", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/surname", + "relativeInputAttributeId": "datasets/6/attributes/surname", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "surname", + "unifiedAttributeId": "unify://unified-data/v1/datasets/79/attributes/surname", + "relativeUnifiedAttributeId": "datasets/79/attributes/surname", + "unifiedDatasetName": "Charlotte_unified_dataset", + "unifiedAttributeName": "surname", + } diff --git a/tests/unit/test_attribute_mapping_collection.py b/tests/unit/test_attribute_mapping_collection.py new file mode 100644 index 00000000..d9668980 --- /dev/null +++ b/tests/unit/test_attribute_mapping_collection.py @@ -0,0 +1,134 @@ +from functools import partial +import json +from unittest import TestCase + +import responses + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth +from tamr_unify_client.project.attribute_mapping.collection import ( + AttributeMappingCollection, +) + + +class TestAttributeMappingCollection(TestCase): + def setUp(self): + auth = UsernamePasswordAuth("username", "password") + self.unify = Client(auth) + + @responses.activate + def test_by_resource_id(self): + url = "http://localhost:9100/api/versioned/v1/projects/4/attributeMappings" + responses.add(responses.GET, url, json=self.mappings_json) + tester = AttributeMappingCollection(self.unify, url) + by_resource = tester.by_resource_id("19629-12") + self.assertEqual( + by_resource.unified_attribute_name, + self.mappings_json[0]["unifiedAttributeName"], + ) + + @responses.activate + def test_by_relative_id(self): + url = "http://localhost:9100/api/versioned/v1/projects/4/attributeMappings" + responses.add(responses.GET, url, json=self.mappings_json) + tester = AttributeMappingCollection(self.unify, url) + by_relative = tester.by_relative_id("projects/4/attributeMappings/19629-12") + self.assertEqual( + by_relative.unified_attribute_name, + self.mappings_json[0]["unifiedAttributeName"], + ) + + @responses.activate + def test_create(self): + def create_callback(request, snoop): + snoop["payload"] = request.body + return 200, {}, json.dumps(self.mappings_json[0]) + + url = "http://localhost:9100/api/versioned/v1/projects/4/attributeMappings" + responses.add(responses.GET, url, json=self.mappings_json) + snoop_dict = {} + responses.add_callback( + responses.POST, url, partial(create_callback, snoop=snoop_dict) + ) + map_collection = AttributeMappingCollection( + self.unify, "projects/4/attributeMappings" + ) + test = map_collection.create(self.create_json) + self.assertEqual(test.input_dataset_name, self.create_json["inputDatasetName"]) + self.assertEqual(json.loads(snoop_dict["payload"]), self.create_json) + + create_json = { + "id": "unify://unified-data/v1/projects/1/attributeMappings/19594-14", + "relativeId": "projects/1/attributeMappings/19594-14", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/suburb", + "relativeInputAttributeId": "datasets/6/attributes/suburb", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "suburb", + "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/suburb", + "relativeUnifiedAttributeId": "datasets/8/attributes/suburb", + "unifiedDatasetName": "Project_1_unified_dataset", + "unifiedAttributeName": "suburb", + } + + mappings_json = [ + { + "id": "unify://unified-data/v1/projects/4/attributeMappings/19629-12", + "relativeId": "projects/4/attributeMappings/19629-12", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/surname", + "relativeInputAttributeId": "datasets/6/attributes/surname", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "surname", + "unifiedAttributeId": "unify://unified-data/v1/datasets/79/attributes/surname", + "relativeUnifiedAttributeId": "datasets/79/attributes/surname", + "unifiedDatasetName": "Charlotte_unified_dataset", + "unifiedAttributeName": "surname", + }, + { + "id": "unify://unified-data/v1/projects/4/attributeMappings/19629-17", + "relativeId": "projects/4/attributeMappings/19629-17", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/address_1", + "relativeInputAttributeId": "datasets/6/attributes/address_1", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "address_1", + "unifiedAttributeId": "unify://unified-data/v1/datasets/79/attributes/surname", + "relativeUnifiedAttributeId": "datasets/79/attributes/surname", + "unifiedDatasetName": "Charlotte_unified_dataset", + "unifiedAttributeName": "surname", + }, + { + "id": "unify://unified-data/v1/projects/4/attributeMappings/19630-16", + "relativeId": "projects/4/attributeMappings/19630-16", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/street_number", + "relativeInputAttributeId": "datasets/6/attributes/street_number", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "street_number", + "unifiedAttributeId": "unify://unified-data/v1/datasets/79/attributes/street_number", + "relativeUnifiedAttributeId": "datasets/79/attributes/street_number", + "unifiedDatasetName": "Charlotte_unified_dataset", + "unifiedAttributeName": "street_number", + }, + { + "id": "unify://unified-data/v1/projects/4/attributeMappings/19631-17", + "relativeId": "projects/4/attributeMappings/19631-17", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/address_1", + "relativeInputAttributeId": "datasets/6/attributes/address_1", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "address_1", + "unifiedAttributeId": "unify://unified-data/v1/datasets/79/attributes/address_1", + "relativeUnifiedAttributeId": "datasets/79/attributes/address_1", + "unifiedDatasetName": "Charlotte_unified_dataset", + "unifiedAttributeName": "address_1", + }, + { + "id": "unify://unified-data/v1/projects/4/attributeMappings/19632-9", + "relativeId": "projects/4/attributeMappings/19632-9", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/date_of_birth", + "relativeInputAttributeId": "datasets/6/attributes/date_of_birth", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "date_of_birth", + "unifiedAttributeId": "unify://unified-data/v1/datasets/79/attributes/Birthday", + "relativeUnifiedAttributeId": "datasets/79/attributes/Birthday", + "unifiedDatasetName": "Charlotte_unified_dataset", + "unifiedAttributeName": "Birthday", + }, + ] diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index 068f612e..28496000 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -88,11 +88,23 @@ def test_project_get_input_datasets(self): def test_return_attribute_collection(self): responses.add(responses.GET, self.projects_url, json=self.project_json) project = self.unify.projects.by_external_id(self.project_external_id) - attribute_configs = project.as_mastering().attribute_configurations() + attribute_configs = project.attribute_configurations() self.assertEqual( attribute_configs.api_path, "projects/1/attributeConfigurations" ) + @responses.activate + def test_return_attribute_mapping(self): + responses.add(responses.GET, self.projects_url, json=self.project_json) + map_url = "http://localhost:9100/api/versioned/v1/projects/1/attributeMappings" + responses.add(responses.GET, map_url, json=self.mappings_json) + project = self.unify.projects.by_external_id(self.project_external_id) + attribute_mappings = project.attribute_mappings() + self.assertEqual( + attribute_mappings.by_resource_id("19689-14").unified_dataset_name, + self.mappings_json[0]["unifiedDatasetName"], + ) + dataset_external_id = "1" datasets_url = f"http://localhost:9100/api/versioned/v1/datasets?filter=externalId=={dataset_external_id}" dataset_json = [ @@ -170,3 +182,234 @@ def test_return_attribute_collection(self): "isNullable": True, }, ] + + mappings_json = [ + { + "id": "unify://unified-data/v1/projects/1/attributeMappings/19689-14", + "relativeId": "projects/1/attributeMappings/19689-14", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/suburb", + "relativeInputAttributeId": "datasets/6/attributes/suburb", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "suburb", + "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/suburb", + "relativeUnifiedAttributeId": "datasets/8/attributes/suburb", + "unifiedDatasetName": "Project_1_unified_dataset", + "unifiedAttributeName": "suburb", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeMappings/19690-7", + "relativeId": "projects/1/attributeMappings/19690-7", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/sex", + "relativeInputAttributeId": "datasets/6/attributes/sex", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "sex", + "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/sex", + "relativeUnifiedAttributeId": "datasets/8/attributes/sex", + "unifiedDatasetName": "Project_1_unified_dataset", + "unifiedAttributeName": "sex", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeMappings/19691-18", + "relativeId": "projects/1/attributeMappings/19691-18", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/address_2", + "relativeInputAttributeId": "datasets/6/attributes/address_2", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "address_2", + "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/address_2", + "relativeUnifiedAttributeId": "datasets/8/attributes/address_2", + "unifiedDatasetName": "Project_1_unified_dataset", + "unifiedAttributeName": "address_2", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeMappings/19692-8", + "relativeId": "projects/1/attributeMappings/19692-8", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/age", + "relativeInputAttributeId": "datasets/6/attributes/age", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "age", + "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/age", + "relativeUnifiedAttributeId": "datasets/8/attributes/age", + "unifiedDatasetName": "Project_1_unified_dataset", + "unifiedAttributeName": "age", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeMappings/19693-6", + "relativeId": "projects/1/attributeMappings/19693-6", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/culture", + "relativeInputAttributeId": "datasets/6/attributes/culture", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "culture", + "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/culture", + "relativeUnifiedAttributeId": "datasets/8/attributes/culture", + "unifiedDatasetName": "Project_1_unified_dataset", + "unifiedAttributeName": "culture", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeMappings/19694-16", + "relativeId": "projects/1/attributeMappings/19694-16", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/street_number", + "relativeInputAttributeId": "datasets/6/attributes/street_number", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "street_number", + "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/street_number", + "relativeUnifiedAttributeId": "datasets/8/attributes/street_number", + "unifiedDatasetName": "Project_1_unified_dataset", + "unifiedAttributeName": "street_number", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeMappings/19695-15", + "relativeId": "projects/1/attributeMappings/19695-15", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/postcode", + "relativeInputAttributeId": "datasets/6/attributes/postcode", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "postcode", + "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/postcode", + "relativeUnifiedAttributeId": "datasets/8/attributes/postcode", + "unifiedDatasetName": "Project_1_unified_dataset", + "unifiedAttributeName": "postcode", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeMappings/19696-19", + "relativeId": "projects/1/attributeMappings/19696-19", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/phone_number", + "relativeInputAttributeId": "datasets/6/attributes/phone_number", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "phone_number", + "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/phone_number", + "relativeUnifiedAttributeId": "datasets/8/attributes/phone_number", + "unifiedDatasetName": "Project_1_unified_dataset", + "unifiedAttributeName": "phone_number", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeMappings/19697-20", + "relativeId": "projects/1/attributeMappings/19697-20", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/soc_sec_id", + "relativeInputAttributeId": "datasets/6/attributes/soc_sec_id", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "soc_sec_id", + "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/soc_sec_id", + "relativeUnifiedAttributeId": "datasets/8/attributes/soc_sec_id", + "unifiedDatasetName": "Project_1_unified_dataset", + "unifiedAttributeName": "soc_sec_id", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeMappings/19698-5", + "relativeId": "projects/1/attributeMappings/19698-5", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/rec2_id", + "relativeInputAttributeId": "datasets/6/attributes/rec2_id", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "rec2_id", + "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/rec2_id", + "relativeUnifiedAttributeId": "datasets/8/attributes/rec2_id", + "unifiedDatasetName": "Project_1_unified_dataset", + "unifiedAttributeName": "rec2_id", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeMappings/19699-9", + "relativeId": "projects/1/attributeMappings/19699-9", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/date_of_birth", + "relativeInputAttributeId": "datasets/6/attributes/date_of_birth", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "date_of_birth", + "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/date_of_birth", + "relativeUnifiedAttributeId": "datasets/8/attributes/date_of_birth", + "unifiedDatasetName": "Project_1_unified_dataset", + "unifiedAttributeName": "date_of_birth", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeMappings/19700-10", + "relativeId": "projects/1/attributeMappings/19700-10", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/title", + "relativeInputAttributeId": "datasets/6/attributes/title", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "title", + "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/title", + "relativeUnifiedAttributeId": "datasets/8/attributes/title", + "unifiedDatasetName": "Project_1_unified_dataset", + "unifiedAttributeName": "title", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeMappings/19701-17", + "relativeId": "projects/1/attributeMappings/19701-17", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/address_1", + "relativeInputAttributeId": "datasets/6/attributes/address_1", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "address_1", + "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/address_1", + "relativeUnifiedAttributeId": "datasets/8/attributes/address_1", + "unifiedDatasetName": "Project_1_unified_dataset", + "unifiedAttributeName": "address_1", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeMappings/19702-4", + "relativeId": "projects/1/attributeMappings/19702-4", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/rec_id", + "relativeInputAttributeId": "datasets/6/attributes/rec_id", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "rec_id", + "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/rec_id", + "relativeUnifiedAttributeId": "datasets/8/attributes/rec_id", + "unifiedDatasetName": "Project_1_unified_dataset", + "unifiedAttributeName": "rec_id", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeMappings/19703-13", + "relativeId": "projects/1/attributeMappings/19703-13", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/state", + "relativeInputAttributeId": "datasets/6/attributes/state", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "state", + "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/state", + "relativeUnifiedAttributeId": "datasets/8/attributes/state", + "unifiedDatasetName": "Project_1_unified_dataset", + "unifiedAttributeName": "state", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeMappings/19704-22", + "relativeId": "projects/1/attributeMappings/19704-22", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/family_role", + "relativeInputAttributeId": "datasets/6/attributes/family_role", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "family_role", + "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/family_role", + "relativeUnifiedAttributeId": "datasets/8/attributes/family_role", + "unifiedDatasetName": "Project_1_unified_dataset", + "unifiedAttributeName": "family_role", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeMappings/19705-21", + "relativeId": "projects/1/attributeMappings/19705-21", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/blocking_number", + "relativeInputAttributeId": "datasets/6/attributes/blocking_number", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "blocking_number", + "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/blocking_number", + "relativeUnifiedAttributeId": "datasets/8/attributes/blocking_number", + "unifiedDatasetName": "Project_1_unified_dataset", + "unifiedAttributeName": "blocking_number", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeMappings/19706-12", + "relativeId": "projects/1/attributeMappings/19706-12", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/surname", + "relativeInputAttributeId": "datasets/6/attributes/surname", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "surname", + "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/surname", + "relativeUnifiedAttributeId": "datasets/8/attributes/surname", + "unifiedDatasetName": "Project_1_unified_dataset", + "unifiedAttributeName": "surname", + }, + { + "id": "unify://unified-data/v1/projects/1/attributeMappings/19707-11", + "relativeId": "projects/1/attributeMappings/19707-11", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/given_name", + "relativeInputAttributeId": "datasets/6/attributes/given_name", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "given_name", + "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/given_name", + "relativeUnifiedAttributeId": "datasets/8/attributes/given_name", + "unifiedDatasetName": "Project_1_unified_dataset", + "unifiedAttributeName": "given_name", + }, + ] From 75b86fca707a7f66d99f5c1d3bdfd3283cd07714 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 1 Aug 2019 08:50:05 -0400 Subject: [PATCH 112/632] record cluster versions --- tamr_unify_client/mastering/project.py | 38 +++++++++++--- .../mastering/published_cluster/record.py | 52 +++++++++++++++++++ .../published_cluster/record_version.py | 34 ++++++++++++ 3 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 tamr_unify_client/mastering/published_cluster/record.py create mode 100644 tamr_unify_client/mastering/published_cluster/record_version.py diff --git a/tamr_unify_client/mastering/project.py b/tamr_unify_client/mastering/project.py index 71736a37..e94a045d 100644 --- a/tamr_unify_client/mastering/project.py +++ b/tamr_unify_client/mastering/project.py @@ -7,6 +7,7 @@ from tamr_unify_client.mastering.published_cluster.configuration import ( PublishedClustersConfiguration, ) +from tamr_unify_client.mastering.published_cluster.record import RecordPublishedCluster from tamr_unify_client.mastering.published_cluster.resource import PublishedCluster from tamr_unify_client.project.resource import Project @@ -139,20 +140,43 @@ def published_cluster_stats(self): def published_cluster_versions(self, cluster_ids): """Retrieves version information for the specified published clusters. + See https://docs.tamr.com/reference#retrieve-published-clusters-given-cluster-ids. :param cluster_ids: The persistent IDs of the clusters to get version information for. - :type cluster_ids: list[str] + :type cluster_ids: iterable[str] :return: A stream of the published clusters. :rtype: Python generator yielding :class:`~tamr_unify_client.mastering.published_cluster.resource.PublishedCluster` """ - stringified_ids = "\n".join( - json.dumps(cluster_id) for cluster_id in cluster_ids - ) - url = self.api_path + "/publishedClusterVersions" + path = self.api_path + "/publishedClusterVersions" + return self._cluster_versions(PublishedCluster, cluster_ids, path) + + def record_published_cluster_versions(self, record_ids): + """Retrieves version information for the published clusters of the given records. + See https://docs.tamr.com/reference#retrieve-published-clusters-given-record-ids. + + :param record_ids: The Tamr IDs of the records to get cluster version information for. + :type record_ids: iterable[str] + :return: A stream of the relevant published clusters. + :rtype: Python generator yielding :class:`~tamr_unify_client.mastering.published_cluster.record.RecordPublishedCluster` + """ + path = self.api_path + "/recordPublishedClusterVersions" + return self._cluster_versions(RecordPublishedCluster, record_ids, path) + + def _cluster_versions(self, cluster_class, ids, endpoint): + """Retrieves version information for published clusters. + + :param cluster_class: The class to create instances of. + :param ids: The IDs of the clusters or records to get version information for. + :type ids: iterable[str] + :param endpoint: The endpoint to call for versions. + :type endpoint: str + :return: A stream of the published clusters. + """ + string_ids = "\n".join(json.dumps(i) for i in ids) - with self.client.post(url, data=stringified_ids, stream=True) as response: + with self.client.post(endpoint, data=string_ids, stream=True) as response: for line in response.iter_lines(): - yield PublishedCluster(json.loads(line)) + yield cluster_class(json.loads(line)) def estimate_pairs(self): """Returns pair estimate information for a mastering project diff --git a/tamr_unify_client/mastering/published_cluster/record.py b/tamr_unify_client/mastering/published_cluster/record.py new file mode 100644 index 00000000..f627c26b --- /dev/null +++ b/tamr_unify_client/mastering/published_cluster/record.py @@ -0,0 +1,52 @@ +from tamr_unify_client.mastering.published_cluster.record_version import ( + RecordPublishedClusterVersion, +) + + +class RecordPublishedCluster: + """A representation of a published cluster of a record in a mastering project with version information. + See https://docs.tamr.com/reference#retrieve-published-clusters-given-record-ids. + + This is not a `BaseResource` because it does not have its own API endpoint. + + :param data: The JSON entity representing this + :class:`~tamr_unify_client.mastering.published_cluster.record.RecordPublishedCluster`. + """ + + def __init__(self, data): + self._data = data + + @property + def entity_id(self): + """:type: str""" + return self._data.get("entityId") + + @property + def source_id(self): + """:type: str""" + return self._data.get("sourceId") + + @property + def origin_entity_id(self): + """:type: str""" + return self._data.get("originEntityId") + + @property + def origin_source_id(self): + """:type: str""" + return self._data.get("originSourceId") + + @property + def versions(self): + """:type: list[:class:`~tamr_unify_client.mastering.published_cluster.record_version.RecordPublishedClusterVersion`]""" + return [RecordPublishedClusterVersion(v) for v in self._data.get("versions")] + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"entity_id={self.entity_id!r}, " + f"source_id={self.source_id!r}, " + f"origin_entity_id={self.origin_entity_id!r}, " + f"origin_source_id={self.origin_source_id!r})" + ) diff --git a/tamr_unify_client/mastering/published_cluster/record_version.py b/tamr_unify_client/mastering/published_cluster/record_version.py new file mode 100644 index 00000000..0694a2f2 --- /dev/null +++ b/tamr_unify_client/mastering/published_cluster/record_version.py @@ -0,0 +1,34 @@ +class RecordPublishedClusterVersion: + """A version of a published cluster in a mastering project. + + This is not a `BaseResource` because it does not have its own API endpoint. + + :param data: The JSON entity representing this version. + """ + + def __init__(self, data): + self._data = data + + @property + def version(self): + """:type: str""" + return self._data.get("version") + + @property + def timestamp(self): + """:type: str""" + return self._data.get("timestamp") + + @property + def cluster_id(self): + """:type: str""" + return self._data.get("clusterId") + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"version={self.version!r}, " + f"timestamp={self.timestamp!r}, " + f"name={self.cluster_id!r})" + ) From 62b294fd85c8815b4814623ff56228f124bb87bb Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 1 Aug 2019 08:50:33 -0400 Subject: [PATCH 113/632] tests for record versions --- tests/unit/test_published_cluster_version.py | 86 ++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/unit/test_published_cluster_version.py b/tests/unit/test_published_cluster_version.py index e0a105d6..cc45561c 100644 --- a/tests/unit/test_published_cluster_version.py +++ b/tests/unit/test_published_cluster_version.py @@ -7,6 +7,10 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth from tamr_unify_client.mastering.published_cluster.metric import Metric +from tamr_unify_client.mastering.published_cluster.record import RecordPublishedCluster +from tamr_unify_client.mastering.published_cluster.record_version import ( + RecordPublishedClusterVersion, +) from tamr_unify_client.mastering.published_cluster.resource import PublishedCluster from tamr_unify_client.mastering.published_cluster.version import ( PublishedClusterVersion, @@ -38,6 +42,30 @@ def test_cluster_version(self): for actual, expected in zip(version.metrics, metrics): self.assertEqual(actual.__repr__(), expected.__repr__()) + def test_record_cluster_version(self): + version_json = self._record_versions_json[0]["versions"][0] + version = RecordPublishedClusterVersion(version_json) + + self.assertEqual(version.version, version_json["version"]) + self.assertEqual(version.timestamp, version_json["timestamp"]) + self.assertEqual(version.cluster_id, version_json["clusterId"]) + + def test_record_cluster(self): + record_json = self._record_versions_json[0] + record = RecordPublishedCluster(record_json) + versions = record.versions + expected_versions = [ + RecordPublishedClusterVersion(v) for v in record_json["versions"] + ] + + self.assertEqual(record.entity_id, record_json["entityId"]) + self.assertEqual(record.origin_entity_id, record_json["originEntityId"]) + self.assertEqual(record.origin_source_id, record_json["originSourceId"]) + self.assertEqual(record.source_id, record_json["sourceId"]) + self.assertEqual(len(versions), len(expected_versions)) + for actual, expected in zip(versions, expected_versions): + self.assertEqual(actual.__repr__(), expected.__repr__()) + def test_cluster(self): cluster_json = self._versions_json[0] cluster = PublishedCluster(cluster_json) @@ -75,6 +103,33 @@ def create_callback(request, snoop): self.assertEqual(actual.__repr__(), expected.__repr__()) self.assertEqual(len(actual.versions), len(expected.versions)) + @responses.activate + def test_get_record_versions(self): + def create_callback(request, snoop): + snoop["payload"] = request.body + return 200, {}, "\n".join(json.dumps(c) for c in self._record_versions_json) + + p = Project.from_json(self.unify, self._project_json).as_mastering() + base_url = "http://localhost:9100/api/versioned/v1" + post_url = f"{base_url}/{p.api_path}/recordPublishedClusterVersions" + snoop = {} + responses.add_callback( + responses.POST, post_url, partial(create_callback, snoop=snoop) + ) + + clusters = list(p.record_published_cluster_versions(self._record_ids)) + expected_clusters = [ + RecordPublishedCluster(c) for c in self._record_versions_json + ] + + self.assertEqual( + snoop["payload"], "\n".join([json.dumps(i) for i in self._record_ids]) + ) + self.assertEqual(len(clusters), len(expected_clusters)) + for actual, expected in zip(clusters, expected_clusters): + self.assertEqual(actual.__repr__(), expected.__repr__()) + self.assertEqual(len(actual.versions), len(expected.versions)) + _project_json = { "id": "unify://unified-data/v1/projects/1", "name": "Test Project", @@ -180,3 +235,34 @@ def create_callback(request, snoop): ], }, ] + + _record_ids = ["6084737977926081128", "-4650342988873587155"] + + _record_versions_json = [ + { + "entityId": "-4650342988873587155", + "sourceId": "mastering_unified_dataset", + "originEntityId": "63730", + "originSourceId": "Acme_online.csv", + "versions": [ + { + "version": 323, + "timestamp": "2019-07-17T15:48:40.170Z", + "clusterId": "ca68d64b-755e-32b7-a785-5f9b1f51e420", + } + ], + }, + { + "entityId": "6084737977926081128", + "sourceId": "mastering_unified_dataset", + "originEntityId": "82049", + "originSourceId": "Acme_online.csv", + "versions": [ + { + "version": 323, + "timestamp": "2019-07-17T15:48:40.170Z", + "clusterId": "055908e7-2144-3f46-ba21-4c2e58816228", + } + ], + }, + ] From 2d824bb6c99ab7b95ec3b03e18455c1aea54b036 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 1 Aug 2019 08:50:43 -0400 Subject: [PATCH 114/632] docs and changelog --- CHANGELOG.md | 1 + docs/developer-interface.rst | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06296e48..114b2fd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - Delete records from a dataset by providing records rather than record updates - [#183](https://github.com/Datatamer/unify-client-python/issues/183) Retrieve versions of published clusters - [#220](https://github.com/Datatamer/unify-client-python/issues/220) Delete all records from a dataset + - [#185](https://github.com/Datatamer/unify-client-python/issues/185) Retrieve versions of published clusters for records **BUG FIXES** - [#212](https://github.com/Datatamer/unify-client-python/issues/212) Sped up slow running tests diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index 02c1aa14..055fcb4a 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -161,6 +161,18 @@ Published Cluster Version .. autoclass:: tamr_unify_client.mastering.published_cluster.version.PublishedClusterVersion :members: +Record Published Cluster +"""""""""""""""""""""""" + +.. autoclass:: tamr_unify_client.mastering.published_cluster.record.RecordPublishedCluster + :members: + +Record Published Cluster Version +"""""""""""""""""""""""""""""""" + +.. autoclass:: tamr_unify_client.mastering.published_cluster.record_version.RecordPublishedClusterVersion + :members: + Operation --------- From e4853243f6763b85c895ed09d7cc7d86fa1d28e2 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Mon, 5 Aug 2019 09:59:59 -0400 Subject: [PATCH 115/632] update published cluster docs --- tamr_unify_client/mastering/published_cluster/resource.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tamr_unify_client/mastering/published_cluster/resource.py b/tamr_unify_client/mastering/published_cluster/resource.py index ee2a4193..9219ab62 100644 --- a/tamr_unify_client/mastering/published_cluster/resource.py +++ b/tamr_unify_client/mastering/published_cluster/resource.py @@ -5,10 +5,11 @@ class PublishedCluster: """A representation of a published cluster in a mastering project with version information. + See https://docs.tamr.com/reference#retrieve-published-clusters-given-cluster-ids. This is not a `BaseResource` because it does not have its own API endpoint. - :param data: The JSON entity representing this cluster. + :param data: The JSON entity representing this :class:`~tamr_unify_client.mastering.published_cluster.resource.PublishedCluster`. """ def __init__(self, data): From 6eec9a326c087dbb94c651113fd8dbe51ff67224 Mon Sep 17 00:00:00 2001 From: Charlotte Moremen Date: Mon, 5 Aug 2019 13:57:36 -0400 Subject: [PATCH 116/632] MISSING INDENT --- tamr_unify_client/project/attribute_mapping/collection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_unify_client/project/attribute_mapping/collection.py b/tamr_unify_client/project/attribute_mapping/collection.py index e28e57ed..478c78c2 100644 --- a/tamr_unify_client/project/attribute_mapping/collection.py +++ b/tamr_unify_client/project/attribute_mapping/collection.py @@ -33,7 +33,7 @@ def by_resource_id(self, resource_id): split_id = mapping.resource_id if resource_id == split_id: return mapping - raise LookupError("cannot locate mapping from resource ID") + raise LookupError("cannot locate mapping from resource ID") def by_relative_id(self, relative_id): """Retrieve an item in this collection by relative ID. From 92c4286c12871e5b69b52cd7fd2ff9bb26f4d4dd Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Wed, 7 Aug 2019 09:21:48 -0400 Subject: [PATCH 117/632] fix docs typo --- docs/user-guide/quickstart.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-guide/quickstart.rst b/docs/user-guide/quickstart.rst index 10eebacd..08cf5925 100644 --- a/docs/user-guide/quickstart.rst +++ b/docs/user-guide/quickstart.rst @@ -56,7 +56,7 @@ via the ``by_resource_id`` methods exposed by collections. E.g. To fetch the project with ID ``'1'``:: - project = unify.projects.by_resoure_id('1') + project = unify.projects.by_resource_id('1') Resource relationships ---------------------- From 8466d813a9a41ced33949f724b26dd3d341607d9 Mon Sep 17 00:00:00 2001 From: Charlotte Moremen Date: Wed, 7 Aug 2019 09:46:16 -0400 Subject: [PATCH 118/632] extraneous files removed 2 From 7f9911e71dea2612790b4af31295a0879a7ef3b6 Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Thu, 25 Jul 2019 16:10:01 -0400 Subject: [PATCH 119/632] upstream_datasets() method --- tamr_unify_client/dataset/resource.py | 15 ++++++++++ tamr_unify_client/dataset/upstreamDataset.py | 0 tamr_unify_client/dataset/uri.py | 29 ++++++++++++++++++++ tests/unit/test_upstream_dataset.py | 0 4 files changed, 44 insertions(+) create mode 100644 tamr_unify_client/dataset/upstreamDataset.py create mode 100644 tamr_unify_client/dataset/uri.py create mode 100644 tests/unit/test_upstream_dataset.py diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index bb541ba7..084866c0 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -4,6 +4,7 @@ from tamr_unify_client.base_resource import BaseResource from tamr_unify_client.dataset.profile import DatasetProfile from tamr_unify_client.dataset.status import DatasetStatus +from tamr_unify_client.dataset.uri import DatasetURI from tamr_unify_client.dataset.usage import DatasetUsage from tamr_unify_client.operation import Operation @@ -247,6 +248,20 @@ def from_geo_features(self, features, geo_attr=None): self._features_to_updates(features, record_id, key_attrs, geo_attr) ) + def upstream_datasets(self): + """The Dataset's upstream datasets. + + API returns the URIs of the upstream datasets, + resulting in a list of DatasetURIs, not actual Datasets. + + :return: A list of the Dataset's upstream datasets. + :rtype: list[:class:`~tamr_unify_client.dataset.uri.DatasetURI`] + """ + alias = self.api_path + "/upstreamDatasets" + resources = self.client.get(alias).successful().json() + + return [DatasetURI(self.client, uri) for uri in resources] + @property def __geo_interface__(self): """Retrieve a representation of this dataset that conforms to the Python Geo Interface. diff --git a/tamr_unify_client/dataset/upstreamDataset.py b/tamr_unify_client/dataset/upstreamDataset.py new file mode 100644 index 00000000..e69de29b diff --git a/tamr_unify_client/dataset/uri.py b/tamr_unify_client/dataset/uri.py new file mode 100644 index 00000000..1ea16f9c --- /dev/null +++ b/tamr_unify_client/dataset/uri.py @@ -0,0 +1,29 @@ +class DatasetURI: + """An upstream dataset.""" + + def __init__(self, client, _uri): + self.client = client + self._uri = _uri + + @property + def resource_id(self): + """:type: str""" + return self.dataset_id.split("/")[-1] + + @property + def relative_id(self): + """:type: str""" + return "datasets/" + self.resource_id + + @property + def uri(self): + """:type: str""" + return self._uri + + def __repr__(self): + + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"{self.uri}" + ) diff --git a/tests/unit/test_upstream_dataset.py b/tests/unit/test_upstream_dataset.py new file mode 100644 index 00000000..e69de29b From b0e6d2828411f8555d51dd8e20a229a894092fc1 Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Thu, 25 Jul 2019 16:12:06 -0400 Subject: [PATCH 120/632] upstream_dataset() test file. --- tests/unit/test_upstream_dataset.py | 74 +++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/unit/test_upstream_dataset.py b/tests/unit/test_upstream_dataset.py index e69de29b..f794a2e2 100644 --- a/tests/unit/test_upstream_dataset.py +++ b/tests/unit/test_upstream_dataset.py @@ -0,0 +1,74 @@ +import responses + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth + + +@responses.activate +def test_upstream_dataset(): + + dataset_json = { + "id": "unify://unified-data/v1/datasets/12", + "name": "Project_1_unified_dataset_dedup_features", + "description": "Features for all the rows and values in the source dataset. Used in Dedup Workflow.", + "version": "543", + "keyAttributeNames": ["entityId"], + "tags": [], + "created": { + "username": "admin", + "time": "2019-06-05T18:31:59.327Z", + "version": "212", + }, + "lastModified": { + "username": "admin", + "time": "2019-07-18T14:19:28.133Z", + "version": "22225", + }, + "relativeId": "datasets/12", + "upstreamDatasetIds": ["unify://unified-data/v1/datasets/8"], + "externalId": "Project_1_unified_dataset_dedup_features", + } + + upstream_json = ["unify://unified-data/v1/datasets/8"] + + upstream_ds_json = { + "id": "unify://unified-data/v1/datasets/8", + "name": "Project_1_unified_dataset", + "description": "", + "version": "529", + "keyAttributeNames": ["tamr_id"], + "tags": [], + "created": { + "username": "admin", + "time": "2019-06-05T16:28:11.639Z", + "version": "83", + }, + "lastModified": { + "username": "admin", + "time": "2019-07-22T20:31:23.968Z", + "version": "23146", + }, + "relativeId": "datasets/8", + "upstreamDatasetIds": ["unify://unified-data/v1/datasets/6"], + "externalId": "Project_1_unified_dataset", + "resourceId": "8", + } + + unify = Client(UsernamePasswordAuth("username", "password")) + + url_prefix = "http://localhost:9100/api/versioned/v1/" + dataset_url = url_prefix + "datasets/12" + upstream_url = url_prefix + "datasets/12/upstreamDatasets" + upstream_ds_url = url_prefix + "datasets/8" + + responses.add(responses.GET, dataset_url, json=dataset_json) + responses.add(responses.GET, upstream_url, json=upstream_json) + responses.add(responses.GET, upstream_ds_url, json=upstream_ds_json) + + project_ds = unify.datasets.by_relative_id("datasets/12") + actual_upstream_ds = project_ds.upstream_datasets() + uri_dataset = actual_upstream_ds[0].dataset() + + assert actual_upstream_ds[0].relative_id == upstream_ds_json["relativeId"] + assert actual_upstream_ds[0].resource_id == upstream_ds_json["resourceId"] + assert uri_dataset.name == upstream_ds_json["name"] From df958d092423beaeb1778d6177f0d48eed981ea5 Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Thu, 25 Jul 2019 16:22:34 -0400 Subject: [PATCH 121/632] CHANGELOG changes. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 114b2fd0..ec054484 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - [#183](https://github.com/Datatamer/unify-client-python/issues/183) Retrieve versions of published clusters - [#220](https://github.com/Datatamer/unify-client-python/issues/220) Delete all records from a dataset - [#185](https://github.com/Datatamer/unify-client-python/issues/185) Retrieve versions of published clusters for records + - [#180](https://github.com/Datatamer/unify-client-python/issues/180) Support for getting a dataset's upstream datasets **BUG FIXES** - [#212](https://github.com/Datatamer/unify-client-python/issues/212) Sped up slow running tests From 80a68fb267e5d927dd5cdd9994115e73557d95b2 Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Fri, 26 Jul 2019 16:55:12 -0400 Subject: [PATCH 122/632] URI class --- tamr_unify_client/dataset/upstreamDataset.py | 0 tamr_unify_client/dataset/uri.py | 26 +++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) delete mode 100644 tamr_unify_client/dataset/upstreamDataset.py diff --git a/tamr_unify_client/dataset/upstreamDataset.py b/tamr_unify_client/dataset/upstreamDataset.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tamr_unify_client/dataset/uri.py b/tamr_unify_client/dataset/uri.py index 1ea16f9c..3453aaeb 100644 --- a/tamr_unify_client/dataset/uri.py +++ b/tamr_unify_client/dataset/uri.py @@ -1,14 +1,21 @@ class DatasetURI: - """An upstream dataset.""" + """ + Indentifier of a dataset. - def __init__(self, client, _uri): + :param client: Queried dataset's client. + :type client: :class:`~tamr_unify_client.client.Client` + :param uri: Queried dataset's dataset ID. + :type uri: :py:class:`str` + """ + + def __init__(self, client, uri): self.client = client - self._uri = _uri + self._uri = uri @property def resource_id(self): """:type: str""" - return self.dataset_id.split("/")[-1] + return self._uri.split("/")[-1] @property def relative_id(self): @@ -20,10 +27,17 @@ def uri(self): """:type: str""" return self._uri - def __repr__(self): + def dataset(self): + """Fetch the dataset that this identifier points to. + :return: A Unify dataset. + :rtype: :class: `~tamr_unify_client.dataset.resource.Dataset` + """ + return self.client.datasets.by_resource_id(self.resource_id) + + def __repr__(self): return ( f"{self.__class__.__module__}." f"{self.__class__.__qualname__}(" - f"{self.uri}" + f"'{self.uri})'" ) From 376c7c8cee322583a80d875c32073a6ce070e58f Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Tue, 30 Jul 2019 16:48:38 -0400 Subject: [PATCH 123/632] Edits to docs. --- docs/developer-interface.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index 055fcb4a..1faf0064 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -96,6 +96,12 @@ Dataset Status .. autoclass:: tamr_unify_client.dataset.status.DatasetStatus :members: +Dataset URI +^^^^^^^^^^^ + +.. autoclass:: tamr_unify_client.dataset.uri.DatasetURI + :members: + Dataset Usage ^^^^^^^^^^^^^ From bca2264a9250c86847c70e5685747fdd6673c0dd Mon Sep 17 00:00:00 2001 From: Charlotte Moremen Date: Wed, 7 Aug 2019 10:35:37 -0400 Subject: [PATCH 124/632] redone, after deleting my own files by accident --- docs/contributor-guide.rst | 21 +++++++++++++++++++++ docs/resource:collectionRequest.png | Bin 0 -> 98891 bytes docs/resource:collectionRoute.png | Bin 0 -> 158090 bytes 3 files changed, 21 insertions(+) create mode 100644 docs/resource:collectionRequest.png create mode 100644 docs/resource:collectionRoute.png diff --git a/docs/contributor-guide.rst b/docs/contributor-guide.rst index e326c8cb..bce59bbb 100644 --- a/docs/contributor-guide.rst +++ b/docs/contributor-guide.rst @@ -135,3 +135,24 @@ Editor config - `python-black `_ - `linter-flake8 `_ + +Overview of Resource and Collection interaction (from_json and from_data confusion) +----------------------------------------------------------------------------------- + +`yourResource` and `yourCollection` are files that inherit from `baseResource` and `baseCollection`. Examples of such files would be `resource.py` and `collection.py` in the `attribute_configuration` folder under `project`. + +.. image:: resource:collectionRoute.png +.. image:: resource:collectionRequest.png +**Step 1 (red)**: `yourCollection`’s `by_relative_id` returns `super.by_relative_id`, which comes from `baseCollection` + +**Step 1a (black)**: within `by_relative_id`, variable `resource_json` is defined as `self.client.get.[etc]`. `Client`’s `.get` returns `self.request` + +**Step 1b (black)**: `client`’s `.request` makes a request to the provided URL (this is the method actually fetching the data) + +**Step 2 (orange)**: `baseCollection`’s `by_relative_id` returns `resource_class.from_json`, which is the `from_json` defined in `yourResource` + +**Step 3 (yellow)**: `yourResource`’s `from_json` returns `super.from_data`, which comes from `baseResource` + +**Step 4 (green)**: `baseResource`’s `from_data` returns `cls` , one of the parameters entered for `from_data`. +`cls` is a `yourResource`, because in `from_json` the return type is specified to be a `yourResource`. +When `cls` is returned, a `yourResource` that has been filled with the data retrieved in `client`’s `.request` is what comes back. diff --git a/docs/resource:collectionRequest.png b/docs/resource:collectionRequest.png new file mode 100644 index 0000000000000000000000000000000000000000..b153519cb9958f90b43f1f9c8e29ab9f0d77bb7a GIT binary patch literal 98891 zcmc$_bzGF));|o00!oLVbdE@OgG$3NG)Q-sbc2Kf(%qmmDj?k&n^n#nIrU#zO)D10q+ z)b!xUICM<<-cXvAqNJB^>%-scj(!c7-bE>Zj0;DuEFxb>7pg@*Abp8UM6EqXv>hTA zMK^Pn{6~BP4u3UXIFLz>qI#}vkgSiSLK;ac(9Lq{1%(y7YoLflZ`N3{vmY z<8^xRJ(K)(d9#Ehp{}Ndmub(wb@PT__skq);qOY`Kp0eJB%{<$*SJb;eaE9XRy#6K znYs_q45e7RE%9X|o~*9&UhPq+5|f%Xy!oxB$|WGp;~AmyH2wP@`?z7S0@+U?HC#QO z`VSyYgyw8+0%<`N-%b5V=sdyQl!Z6*_D&j?ZNv*UDpQB>^s3;sx3xHydMSMo)RplA ziAl$EZzx_6%Z7bJRBj86l`urN*2wWEaMUpQLuiYt?}b9L{(;SzBV zm3@&(A&?OHij1Iin6jCbxY+ijhd5#7hygmEn$Rf~Ee0WZD#HSw#Lw(kNZ@lljx%kb znYi(4DG=n6rUd0=k7UM`#9(kdbbkMU4&})&lfg$kuf_4*;q~w?5x5-jGAi@0hi{g@ zjz$g1X-9|u;d_=8Btc7vHnY|8ZlVM~>D6tqYT4D!V;7Bl#;?yFE^~ED(>x{a^lh|F z@fz2Aeo1L7g*+J!t9;s7L~&gogTv?iJlZcbwUEbDK8isG-z;2J3N%lQ_cP>3&nEIT~oK=#v4vd4P zAcoJkraWsjzT(!kt)vF!ywXU|RPo2{^s_y54_uL`TGXc=*miyFrm{(1ZX~zmy3+KE z2^z=5uc$`bZ;pAzlarGg1)47!12tkvo+u_C%66nPr_+J0$9G|5s_7$jRh6RQxC$JUr?*eN8IP1gepQ1m=G=gn^ z5PpB)Zp<_N*dsW%L;Uw6+z_H=?RTj9kEVVaH2C=ZH#oTyDJNFRV3+vFLdjDmwpPifa!( zec>HP;gj7TbuN(@kTDx2c6*)?Is9hu_hUC9-AMkt=25LPbRX2#n3un;wrC!4vPDJc zn3ShU^!_CL0cOQWG=1{p<@ZN+-MT+;FC~n6h~G+_Q-M~nvpzI;l2C*culV^rZ9_5o zIQ2*5SnGy3fFv*+Z#fssE%@2b5BPG+giJ&(Sk+j`q0c)#I$w9%T=K#R7x5#cm_~!P zFmijbO~swrou4_=JL4S-R%T91QZU5JF)@&;aWV&W(~~5=lqZ*KmhY3(mTN8WG-s<~ zoJpjR9%hK_UtfEE!hWK9LUKZMf-jwFDvMRXpn{$xlthx`kc2zHsK8+n{#Dlf^CNZj z62y4VxUTIJI4_(Eo}7}BQmgfTl7TILlCDg*tX0>lmdduZcpH{wLout7kyV#f7vhoS zk$L_JQalIi+Dbn&y>js(x@EXUz0KyAoKwpD#`fE=#Gqo~tH1PhBd&BGB&>|bpcq^% zW7R&)m>pp7sl1h4Hnq6r!z+?2 zJ-G7D1t;f6oRF?%o;DMEvnmk0u^TJac=7O}0bsLtgYO<@qEz6_*Al zSCLraAgg(~@qyW*aR4W`fGIgvbjtHuG6OCjJJDXjp|{rh7A<|3O~)8SDe?vKLH#ci z`4g{;8cM6T8DKL-nPBD2^H?r2BHoV@MYcn2T%JQJJXX~V)s2>VQ1%R*481hjOvNFA zv|Qf9dSl&Coi)AhwSu;zR{cM)D~WZ=Yeg*)7=v*=mLyfSI>m1vXjy7;X}Nr3(=yPK zoWz|hom`*v{eC#7;&9{OVm~?SHj6#2I}54Fwwkl|?TazpbV{|VUp$Cf(DTK(cDp{@ zo7>A3CKGu_IY5y?c_1`KDM@K1#4l{ zGPh~9o4P)=>$Ojh>??~33Yv9+gY;Ajm9d{nj z^zk>g3$)SCk;v0H zcqtS?1nC(wO*GpzrS1{xlV25P$|C}S^?8mBf$a#kv7s`&BbsEGS9X)jO1>OqSSje1 zhbK~jycwX1!iht2Iq@wl2qgutflo=zr7@)^^Qy zFl<#BUnXWAHdo(`<)mNCyG3UZc)G3-yq7o3rVuu)B#><=c>4=G`&rAg>uOA$ZJp*S z@e@L?=$4=MjvkWdD7jrmBY{kqMBhoyutGk3`=IO+?5edX3dI>VNSsS0&CpJq;#dja zk>WTpI}zAyXOnv|t;(sNX4p_1uAijoCik6#9X~h2hO64mvc6kdjahZ*aaOJ4((_kq8a@4bmCxwZ-P4YI5iu&xOzQ%7?!%p zdZ_!S*%YR*qHsJw{_TzSzJ6JKlfC;$MueU2RMn(bnSZNeZ<8zEB46f|`qbbQi|wlI zoWQi>+r~d;28IjptD`G197`NU@yS6m?R$TwYsl7qn#cJyy&o|Up^TPdb zN|e+0_a${Pa!>X0WsGQ)m)EiCNq)1~O^#LjU;uJj?>OzQVIyKav8Xsl8Sg?GZg6Kd zK;1gRF!J$=1v!3Q{ikvD^g-ZjX$eAPlOf6j8x_x;*jFtDSD~p*s{F;`Eg?n<;VpCW?e3qnn8ro1-iJzli*sj+BL~nTyRkHybBMntQq?rcUl|Vsv!(6aDA!U+c8+ zviW-^N7w(^7O+8%`#T(5?3^6`p$!Zby}v4~V&i3D|5nPz!NSoMm_uBUmtXX+=l{>0 zzi0ffkve~m22Cq7l_uYFQ$Vss>^A}O^WHFZ)opmiy|>0hK8I6;iK25jv5 zjT}}7T&#=5_91dk#d2LOH(G|X(!E>j8`VL@#LW1Z=x9N&X#UsRXAqj_{y*gIhXo5tlmEj^#wcj7vmT)kOJMw;MbO}P zGtd45CA0_lm@B*}pD%^}VQn;!(MSJ4iR?YFW>l1(k$+e0pH>&A?PNh8qgbT$UR#o_W zB$x;dCY?1bw3%hPA#3K|N9kFxo}$Mtb`l>8mZfl(nl(>UD%!T8o4X(X4G=-bHz%eY zlD}mjNy|yu9Z|H+tn97ObTYuwH}PlKv(44#=M%%Y7?Fd9nw3v1G1qK&Sb!RQhz1n$ z!)?Io_J6eXVQhyRKB#-^{R3RN`UQ_Uwac>B^V#i!M2Lc(L+ylGdrt%~^V{dh#n&^cFD^X!y93Z%V(~IxFYArkD+`mz8FO>D3;110jTxz_zeTbwPG5 zfr}Qz1Q5%x?sop0mwghDIj462rk$_fdjHT0cEfV-&Il=jE7S`R(H(c! zhq;T_ix)9s-3QHwIpSwu@S<<2F@jzZ0ZOC9yCV2+K47A6P~I=@{n55^pzyK#n>^d9 z7?{Z7am-maNW3!V)2*nCaTksN1*s5MI7~Dkoks;u1K~k&Atv}+h_J8@H-h58n^o%V zO$nrT)7h9};?Wvjz){HHl@b_kokIt8LT7K@t2*0~l@RtG;d|)(Rj#{@9N1ga7sAc| zNb{BPA`PcOx6L97K1nt{8uFML8Gl*Dr;cCD^xSz~2CyLH@qh97e@1MO02DSRb~A{Q zTW`p|-_xhS4nx_T zYrk3N*oJsEsp|$H*29{a%OBq>rSZLQ!9i8i=l=NKJ=0^}x$o=)ho8xwz8L0;Mtr!x z0C3w~l5sei_Nc!7mTi>v?hjs|Tuep#-Id!ZRG_#Wd3zXf{$XPsn9^#dqO$k7O|que zvu4k7O5lhu2+(~$d}fuUN~hX-*~7uk;Nf9MTnLz{4tts-z&?pDz{@?^%=$E>i}b+V z5jHx-Fy>2W?{(%^Zk@n+mqE7e1JCyBs3)+r?ftqr$L%nQn?Gu?MBO&baw}2~00qY3 zTsbnr{Eh8*(Y&N&bi1Ri*hh!GOX)cu9{^k-ISCb9*c88z_iP+AcpTI;Smh~iS5fVO z1#3cVWP;}Od90qSoK;+iz+`LuW<#+OrOYm}5B)%3U#lx=Des^TY`UXwMz=5h4}F&u zwT=AU(`k?!0`1I&A|o0ZM-J&0De@N$ePRk}hqaTsEMjZ4c=Eg7tA~HpX5p@w52GI&7G4ijSfN>A9!|rSpknLAHc4N|{25A9^K_jN3RbEDcMK3ev5Z}<6yQ|$|;GDJ< z#*#2)YXu4?T!d}a5G7u`-UfEA_-lYfTVHi({f`2sBByVK@4~klRPP_?@wv6hH5ll= zFJZaJo6WN_Cx4b~ua$`Hz3LIj&IDv6@a_~gw{&-YzMX@lM_#mDw5t14i*ZCzKSjT{ zP2F}`O<}(y{5E)U<*9F$F(T#3aT0%;O?MmpGA`+w^WMyciC^!*roybJ{fbwbXdvoA zE8QoYa{G;&IjfF$zhuP|0Od|~-dxGOH|tB7WRl|WQsqJp|82z&ybEqHrG9e~M9E#@ zK8V3w&*THiz}uiWGKn-zo@o9EDj60&z~bXwL>ivu(Q(>OA>@^P)UGO!R~2-x?2-I4 z$Ui;%Ki@`K(~yRyAJNy?gVLV)D$P@${|GBI@Lb>nT$_iw_<7rTZH3bP?&o?)OY<9mM zBdOfSRn4ZlXzpqj&cQ)t8+08;!uR@u*^ysh?wV!~Lj}Io>ZWkS*(QRZ?z&8<|^)xOcXj35x97=AC?BuH2fp z?+^c!kHv0~bE@yt)t)b5RI~jVJ>?hJ*R18fmtEA02*4SP_M6-33w7P(?^~y~&i1~5 zZ@D`#d1sr&2wA4>bn`tyLG`F2g1pXR!d}Q~QtkU%9k6Fc-K>F;I|AWXS$>2z(*ZVMorQu>*kHa(*NZXo`x?qJb^%wJCS!g~ z5K==glg$WaKU1qX64@T}asL>qDXu#uemEVZ!W(L*FvaR?+D=6aJiO%ma-8y_Qe!?_CoeLJ&^P)pgPs?^r+iT=UDGm}1B z+@+hgr|hP$1S&#cEJlcdFPluSvwYfyFHM3mrQU_#NV88ebn5Wqyr9iv zc|C(PVvFt(jAsJtbE%3g6D6Ib$(nTEA|!r#(ITGZ5c4XnZqK7Emh?S z{G*3LHDPA4Vs+>T)+rPF(;~!cU$ciLkT+u=?Ls>@4o7?@tH*v~P|u4_TnHvMCb79C zgnx{Gii$?Wpub*sV(1+cL#viP`Tay$sHq0zJ1wx?aQy_A$Mi2Gs zrl%T3bYO`8?hnrVa**rv9Q2fO&c0d+6oyMp9cJ{} zr}k7l62$LK^R{b7vL^cnO{2cKLURXS7rgpRiUuoprjw_6*pDNsQv-dD@>ZxcLh!ar zF2Ti&O1NL*+xcK{SxlKo&DG8~QmnyQy9$E>GzP&$r73+^B^_nj%t8{IM-d-uZA3}s z73z-JXf;84xc!Fb7|etmUqnAJB)7Zcg;K)7MDiy?J8PQNr7qOkO*@j#tScT zccRYm43dJ^Z$f-5BX`Atd?@QjjeQ*-gjXQFCHZ$*jeqACAASc$jAc3RxO777%ydk% zOc1d}*<&@)ONE&~!evz_FSlv&{N-%wp}srL{H2OQW2VA4M_q}(|J3ML%DR z3oZca#GC`TW76hfVJICsT#^=yfXwd?_RI={UGfrB#L zF(X_323REC+$VRDG0R|8cMb@gYnFNI_BUgZq#(3eF{L=?I=vIhz51m9Y013yB4;u_ zM2?MG)IwV^V2Z{Bx7)1`Z1zR%M7$lJQ_PmAZptkV(uPqah+j00)K;@02lYxWBccRa zc_I73XPH9JO$Sur3PQZx`3xqV9PKwJ+>^ht*Rl50>g`B*1JeB<)(c-@$|7R>@PXC= z!TDj)ryUOTgveOQ&!qX!H~b``iM9!%do42^Ci`((Tyr%K)$NUDBt@RC@O*O=xjt+U zJdSu))M7ZL55(!&NJK7j`-cTIv669p!Ajm+s4c)*U$ZE#AkTY!_-lSZKGWSZ@2W`x zB}f7F=*C(m$ewb2`dUJnB63N^aPv!OiAH+_1AN~0TiK^uS3~={E>x~eF=C2jdd!d# zj{>*+t7uH34D!x~XyQ?xVVMDj`qLQCmVBx6klan^&h1IdEOvh!|0xh4mdkNT+mQ1? zi+ZtumS_rpVy=?jG5c;c)-bJ~J%2sftVi?G=@Uto4E$0jrtCt4$@BdAPn#h?M25?3 zYG`Gq@8Rv-31O03CwGuh9Jd@_aE;^im^*nTz+Q2sd_C(EkR(Qa>whs*pGewIv8&CE zV0~-L(e}ihi1q{sU5$<#$r%Hu?$e^s zanc^L@yfYb1I3ar?TS>5bk`dSkF}-tmZsO6DaMoK%VwwYvaA{kZX3-Zu5*jnIn?I* znR20XAH{K_VZT*+KCqz|i3*0w`2BoH>c&4~o>(5;I)`DL_nNUnor)2`Nl?N1`dQO= z-L{D67!R_%pf%RK6f!#VlCYsc!~@WU#-vFDBbxUwqE>3eJ0h( zb8Nry2RS3Bldth;R&|Ni1r8)GUInN=MG=X0j6U-~j&wu#u{pxC-OOCj4(JEFC0TVy zrKJs=vE<)o?*5_o2O@MTtBk2OkKf3d#Wp*2+odUZ9uw46wiN~4$$L<*$Zg@X zYDXGJcfa3C`iP&UGBy@pF&!4cQe+lEQN)^n?emX<&obdt>dCmGM;+yFZ9;knBEGYf zIKIv*+QDIK^D9ac)S*Z!2xThp_)hmZj*LcjhPT2RN6s>nDA9+_h3ga3pHLzQ8B6ho zi4&pR$u}y@1W4YTR6-I`Z|Q2XqdrHo*ML;{lp-f`V+dUyBpvemyJFc$pZIxY(0ZI@ z;8{L3o4ey?5XET2?~>7{9ku>A%!JL`uluFqCO><(_pDnAS1LbEj;JfwA?=%ux{#^f zY&2PD>-87bWH3wPg?0Dmy`L%dEcMAWPV=)Cn2mb)VQxeVbkilO-THB3)JJb&w&|w2 zUv`AQX`QJ4`wVgPCyz2m{^wsUZ(E+gE86QX==eMop`sLcnNkz8HpSV!6F+a;g1+8X z$t3x>*(u5ijqwf9W$cVIK?*Y3SZ0SecrKZeOJliotOM2Ivk?%rk3FFBC+L=FsH=(Y zH$HFL@aD}bS%O+`7%UkYULJd=5hsj!v z7cYBql9m*-ex|(Ogj(~>{QNQ9{b_pLOJ$`xVPe!wRlkbIBw{|l1)TNCf%AHY+^K=< z_DY}~l1@<|T1r01@A-mpAaaY<8;#ea&ATJ;&l2)>X|Po%&nRGkDNueqx#i%^@wZ<@ zMW%h(N+$!SRy@USK;~5_8qape>K8UFxEhrN(uRId6&@zcOM;?A1}fjG>NHj>-~~>1 z!CJLjJ&zq$$nyr;yNOHzZLNDFzS{82PRv;4X5Ysp8cWg#B`_-Az4*IR38brWX1Y8z zXf&@yU^LHmWh4os>crp&PrY|+9e7!tX(~clPiYosiS8}7!hjiGE@fpO`$XFUONM67 zd@6r=^pA>*h4l!TQfi}M`v5Kbv4tV4aDVTVsFao}%Zlm2DWq%3)tM9W+)2zotIFGP z>ahRXp=f@AAGz-{lp5C_>Xh&8&%{;Oc2jVPa{0(V&@b9{X+@+|-mRj=%XA>|Ly$OW zj)2{M?|_-)h{S#i#j$*F>6Vs`qF$bKna`nSF`|*fD9r&!P@CBV!YfUxw`)J|;Jt8I`5Z zzE6pHHOZcEkb5^TcX-$)*5crN01l@L5KF+^B(2dw*txX=w0?$l^Q92rRBo` z8${{Np|LQvcfAoN1ZmjX_Sb^z@;dR4!+)3(@CFdJg3T|s_BHhg#o7;)}8N!xx`3tEc%J+q~xJayhG{$%MW`}mDQ zHYKl!ca?-nRU)WLv8=dTKlUlDNUTR0;Ibf}S8Mbc#Ai&ZoEY-J<-Iw5uE%92+yaUH zK6udZfSq`ZBnU3eZ`k-MP*zX`i7Gj4M!Rx zC#*qsd7NSeKT9q~!8-B*aZ>xr^|z(f@Wgtm=8DDk5%Ftc(dKk+&fU{t*P}2~g~`Ka zq2>1vdga+b&owJ1za7kx)gZ(2&ll`LF|L?vWA$#QsPD_168rNWGTP~!*Ss0hepFc3 zTbiOgmILA|#JOXv?{Sn5EY9`bRx}bWw_n%06i}n#q$c=nlm*m83~EC* zyMDg~pM_lN3S1ZZ*t0RLE@?1nQEmv@12%QB9^*M{-Z?^rKs zxihJ$kY5d{n?Do5bzdI(Uo3c*ih_JQKX+@@rWX|RA^T=Gvj>b1+J9lk*{5`# z9*t|(8T`&F!Ypk0OWi{z*IRU~0g;U~vtA`@Y-61VQ>2lq0aZVO8wuwGJ(Ai6!?JjM5a)E1W2K93_Z>_v|ffr=xw;PWZg!qU;`v}=~ z#a44M)MhCH#aNLNoP_QTT`HA`P|8zJ2N>Of@SjgewNpC}#Dhhy_O|7OW}T+2jwnK5 zMDjo#AXs<)K@c+tVtRNgPsW&j$>&dWdHj14EKAseruyp0)@lXms7pC@5?wNt{`^e-LHiYFYq4V84fuL_A<{TLY98lxt@BMd9E7lINFMG7bk;Jpr zs)Wr}`vnM!8lu}VfHN7Aqr`Tt;ByepGA+_OwfU=>*<%z_^5 zOex~Zy<)_+OjgzYU5Rw6N)uY2Hs}RA6Aq}pYsd})R%10|A z`l7_!a36sc-Y6#xatZM)s1tNnWi!_^;JONEQlyD+|2?Pa8;`O<%hJ-2pz1?6K8=?g)#{|7HkL zd$KC@Y{%}0wc){426rMconVKs}W^`#u{9SrHJcIEt8UJ|~F$wlas;N!Or&A;KR!tE^r>_ZH zOm<TG#-p>p4@YW!-u^rjD@ZHDq*gG#j~a5eb)WZe^`~1)(MCqoDe@gs>@-M)e{-ku zSb}<2Wh(dD6N2i-o%~kG`*J3D)DM-3MKUP(XGuhS;}JfH3qTph7B4R+u72B|#FL*> zPVT17^7R+qG!cOA#PytRE3AEH&z$=xr}h)m#v5mVMw%;R1bN@CR2z9!t+N!_dRV7z zO9To9g{yPatlXHsdXE)wdTm3M>prRDiWb}m$+~2#_m5|ZnPi)P7lKo40SlaPne|1b zygRyU)ULp<~3f32U(iYauU5dPIwv!H`oMKA>? zJ#Q=}S94k7JJh3V;#2YjbYR3RczCpVyFz7GVUV`rXQa{D%5W*QL@m8w$BPIvv&3#L zc{=~E_Y65FP;Ynaj-Z4yW!iJ&+9h81+YW#9wnS&Hh~O)?r>zEMfGuN%cS@WQ$8fsZ z-IStCK`cg=ZaW8Z@ZK~M?;1CAiZ7pGK)I&XgV3e9w2b$Ka(BV^Y^Q4^z?glf=*9(e zHN!)Pz19x(_X&ojN?GXcm@DH?$^_fV@R#TGu4~T&^0b)Lqv!NNkne9Y%>2pwW-bH2 zhQq#oE7#R0E4s>_ya^W6WH6N=z2}1Ym|*(&fnbx;O(r69NZ)U?hmkWFp6sTeRD5-B z5virJlKP7yM&20SxH3Duf~I~%6*6Pz|Nd0E;k4+PG1Ttm`N9#A=ohgF;;fbP_W8w7 z58arDEz>~e*!ReIjU+w;O!zi0BKgNbP4vQ;8xhDdJD7)emq)V?gWB&%EtcWCuX-Y# zn-I>1MlrBKXh|ezRye|z2m`$p!G7OB+Zb7m2@#AAp$A&kDWAG$Z&H8`+te%c`t_lOOoR~RLw)@xAcz4yxa4B`v`5ABLfOMC zV;Vw7CcbZ@Os-uNZX&Sc=6XHGjLc<{(}8=pip-Yx7c@#`g(f@UM$M8L752BuUU<5; z;H>ca4SJvcS@q{lLpxUhDzCIeo1`pg_a)E1tNq!)DS!OpKf2PcO20lW*dK-nK6HLw z4)4`eLI+aP2y7leE>eKz{zR7wu`yJJb$9Rga0ECXhvDPIB66U*0!r$cs=<c>_HBp_gIX22IBA};>T#A&+L?k}`L2-e)P6@ToWm4r|BUW zV(!yU?YB%B28~Ya1fb{r8=Y-=H&|@qGZ$L{MUVGu;v&>V1ZAwVW}PJN@X#%Bf&QS) zykFOR*R=R2n*hMo)=zOxWD0sXZ;sP*7Ixcki$kV>Mv@vO(Y~C>C~vyG0H0V48>?&= zH+XBaln$~Z^Ps1GeU%#N6;UFiH_qVp>*syt$<;(~6p;~xBG;Loq+~_->az}0umI8S zASMX9_bpbz|) z1>tr&$9goJRKQwcidk=iFv~e%psUFpvTmh_MPI1V>iA^ria&TP>y ztLu}Kc555xLv?`#a~LjYY8yPMWXoYGNW|Y-6OfnA7m*m_l^0J|TkBC%tlf5!ELQ6a z+g}B;>>(+F)oq3S5+AMH+?Zc7KroPVl~f#K2XgY?)dM{=b8Y3=k7fFy{aC%#@K-mY z=Mw96ua*|HB9g7w&{ z53D-177A(_8(8G$?u+YKV(h1#8wz`irEnRa!fhr9ZvnI?D*mbqPeL>^;{5CA+OVB6 zo!sOFh;RugQF0Hny1Ov4Hv001CmNH%8}|UzM6vRvYG|>OEc0Hm32+!pN>+OxetM<% zYT|~!Ih|nS5w@$?S3{9tN1`qO-GHb@o%v<)$ocuRssKA=>)pmurjB2g~vWi(2{7#{aKV&C*ZFq)?WE^;QE-GXF?I|2P z|2&}>V^I=tRy(-w6FH;OQC)Umq%704?(oi>f>8;2XNN;2t%ik@v4rew?G1s(zw(y< zgz)6;XW_crtnV;>+m(>Zlhk&=ZX1oH)vFIN?-}`bJm(f+Uq?RhPDQ|g zM#>x)Rn75f`?^MxPQlPa{mgskBt;(sm_5KQyP+_N&I>^nHI(01E}(Fy0uJBql| zLuwfOB0tJAslEw)qHQ=>mCL?z2DHB%^@U_xFG|WMZmy=<#T`!~rD!=}*=;~6Foq{H zz*iqEi?Kd5vY7T9EQ$QEnA3AOsEI3Ld{s&8$>>H^P2F|8i zylU|KKG6-X)qy#nmHn8O`rIG8Y50m>$&|xo-RSUg_w!uJRV$*L0#5-=koRz#)VxsJ zkW~NO&j#s;bk}p3!oYn5!n=B7D*ayb)v2C6RV2Fr=@{T?!GGW;N_#t%%a4KBb5Z|& zr4wbaAp8R;K%W5J(FoXcW%uhAYegoq9DmA*~W3pJ7Bk za~!*UaW`O!))Okv1um-y6{Y35WjJj;aUWT_yQ(*oAa!`YR3mIX?TfLn=v@zHEnrEL zJ`J$Ulap%aCF+((I0atM^&x(jZoY`519D8NI1Ci4q zo<25Khq&Buw;Dtnz#yjQN=S(j3o({7?8M8CR#uL;27_=M@ojnKotH^A8>HQ(5i%4Q zXxXNB&G%3Ayypz zwxiL4+n93h2y1g3?4`_ow6ZR*+zKV@cEafi( z^g&tg-e?E1uTON_f3Yzw5@pgr^FPZt>e4!j*KS!MS!FtrBYGqD0!r*0{7|XH%GPnV zv`)-=#i(=Q5kwoiKlRd`O`RqfJSL?@#v#K5+jPw!eJwO<61(DFyY#`OWC)cmTFQe zzIGZ2NyIb;nj-#hq*LvLmf4cpZPCu1ozeB|cS*jf2%`71L8UJPLWOw|U61jsp6t;; zsz3N8iKOB;Bo3^_dcI*6F(_`q-=A)sVPIaz#NaW&WV?NDSf+2it5O#c!=6PHO(GVqO_it6kUg55Dw&tr&b`kp4cCiB zogwM(3-`!!t8kokU7>u6lvTY97HsMRs90l&&D`9RP`>qk^#P)wL_r*xOyb6H9tX1PDA#Wb%VL3b*Q_Bo;1lk%)Jr;^UuHQO5(F)#&{z)L;C$D=>b83s0sT>QhsA0 zU=sK(2$Q(w!8&Mfcb0X+s+O1Afj59H|PFiM+Ko$x5Cxc(=)z25?fuf0k zI+L~8bdwbTxM8ud$eSrkt8`6ADWCwGV;j8YLhZCb`t4=#o&R&%^`+{7DYIefyQEA zbwJ9VHP;bi;Ml%ashPy0}mZTovDrduaDLCDLQpU5is zE(Toh4+_L9aVXx_GA{M^k~?Lcn141jJl{v|UWgpzO-iIorPP(~`Lwn%X0{XnB`-P) zs0D)E57hQxpMwaR^nsiC2}SRE3O#!ftHz7l-wo8d$jgn|Qr#SG?k`3=-n#W{@IyXr zqePDdhbc8u37-p4uA7U`n3l5c&a5E8J+Rw5ybL~ zF2eD&^GuP%riI@yCgaR%<7w{&pnsp3pVSYKP281DA#<a>aS~wYKTVsBL)qFdCF~41$opbK9 zZfJoEYva_bNfOe#_5+&q#ONn$#+{{Q&Zdu3vE;1^leL~Fg5xg}2=!L`gFkmVzzTTz z#2e|>ukNt}r<)SDjZXe)91)N{{gOrPixWmotr#(RzQ7{gr$jY>;ch_Dl&EAo{O&{;kcfa(6-0f1dPirr3z@?9Do{93x3r6eeooWmgO24&O*Bp3cHX_+9t2@C8 zyUmx;$`||fU{!D_-!dmAIe)0>Sbe9c)P;?x<8{C9XJ%cCWX;|Qqnuf=f9h=1rbFub zg6^yO?~CG6zYf#K1Sd8vZ6}pZ(W$2=JZV|<&^xk$#v?rUk|?N-9EDOo~=09 z@l>t&20y4P{&^<8Q+tjk-x7`@z2kG z<)x%Q33^Tc~S6kX6Yog4=wz5&->fctA8c~1a%e`y&=-F0EyQ?rm;@2Xa%3sFttAK81 z#qfc(A9Lp47ndBI)cSHiISu~!$+FYMElXqxguY^p`&wFJR#hPGoRT1-hyklA7Jb1} zc_Nh&l~XDYJdiu*C?&rIR-o)F{16w{&e?BZ_?sdC(CZmhvx5K=fkpy>o`(DRv{~$% zGg$}8BYehKoz))^5wrYr_B@V3n}&jur)}zM?kX$03~{oV9g}`LIqytgMyQvOR*Q`m z;}7X-wYTn`5zJ1#=mz2pY0>kbj&}TCv#F4Z5AWbu;Mr(ob@Ijo=U}MGjB>ShyTx!^ z**nW8ok8pD3@Z#TQoU{KJ&(g>Me}ya`0+nC7^~87GOLSicnld8i0UZM1k_VpcMZJ= zBUT1araj!cmvB2k!zdPl&YgE!li0_?s`6B1=`RUza21s zx#?{?kZ^ti)|do8OuyJ`SdHlce>LRU5x1LEGjy^ULRU_IHxvX?&;nCW=*$dlrO8=r zUL=twvms-34O_)AjO1pDzoiLsdw$*U^ga>NYP%@*lShDS(|EXv=G|bj>%#VlRZsIQ zh29-5SVUN!3~^dM|2YoilI>!y#nIG_&OQh#z$1uo0$In2dBQJNrIVa0Je^X{)hg$( z-0S(xF`(om-~kZ{gxVRZ@9=uKgg>zSsPj24?a0EYv65dyiN9uI?^JWK*s~uTpUzS( zU6hdShNC)=pty!L_&w<5(w9xk6O2JHc<>?>B-~>^A!2r(ayz3ix2+w1F+#x-kQwCs%zA!fXRd~>3yM`*5S zL9E!!W}-y9wjUS2ryYyx;WIGV=bvdqMVVYhsljm3T<;KXWe(VDrYUM5#Kg!5QGWC- zVzD_63}$2#|V=K6?++rd}r40`k}c(g^3-C1{S?+JNnW9 zPtk)>S0K%HZt@eg^lh;Em(3CwfA!QCdM~}`yWzOJNFzQ8E9vfMm@_9%=M-|A2V2k~ z!X#4EJ@pP8E$FgpMWBi)!so0r>1|0Sc|}7R;OEsJKf8Gh@E|CYj9TlL#4!|J5c!XU z3#J#GD93||<4H#{lo)#v)bK&q=_RlRf>YD))@bnJ`$f}ctAZ9wS&JGigy~wRTEPBX z(V)1MOrt8L$o`Slp&MYMA%j+?MHC^YQk8s1gUG|cYhPyHbcl=E# z0aSHmQ47BX-{)?=&Z$v%D5=vHmt?xR7dw-L9r(FtYyhUjn;V34| zK{O^{k!{IKrxV$=DN&eB8$PJP+;yV{d3Svg`a+MHIKei6H+Ad{9i;aMMO;2+_#D}8 zF&3uFyP|XD<3JkD;QZG)6Ec6H)BgpT2Y$Knw3j${LDX}{cBOlKR@g%)je8XZN~$i_ z!TIMNH9AWoIc@pc34}4fW&UM7X|-g}p6@pT088mjCSXsHHnW~mSvuxOg>N6vZ~Wz5z-}B#UN~Q zH#q}4UAgXlo>m}>+}$38UI=7bd)8dY-TwwdK7D6BRz&T;mO+P})`RtWIJHsRbU3^A zC1aLAu90{l_;VVRXWgQAU?{bpJJE=#%8%@{$;CnlXS0CfSE&`*+5gAXTZcszukF8r zq<}D#bTfz`-Q69MQqr9QA`Mak(hS{_(%s$NB}k`q*HFJT?!Di0{^jC=ne~mQ?$5o< z7cPJ=+S*8X`exe|Ex?B!`GH=G&2Nie?=g5?Tk-GmxHo+MNcEjK`|MasUe!aGGv`?W zo{-lB+nXzBlYvaWN57 zzUsw~$1YJoLfYk?ct-O-?y94P+HlHYSs3C2xe%58K;9F|%(4-veikE*pF9pQ zYx6hWLhe0|#ORb(B3({O6k+MV*RLyP1}>as-FfUiQqK1r;KTAMXUjv$9T!{YRs_?#473Jq#auBA1|L-@G{GZ3U;csAFeSR z+{EdEUC2Fto~WFT_}$KAUl@|s`e9Jj~n7r2Ik z=@ogw(gAQ^{vT%(N=?DiiCK0l3hjE+9_@rsZ)&7s)22wHJv?T-Iqp8@JOzL)yfb3C z7w>r_g!K%OyGu}I6ZJi;P;VzrprY-lV{rYW+^bY}EoanW?Mj|@!=moD-aiq8ErqIG zZeW$(B_O+MxINKk`25=xgWX1q)cbPMOt9PNB!n|gsGTI|LxYb5)cJXaDy3m*z(TwZS zuzN94i8!DHfMCS%(_OzY+N)fzp%n-^VoK0^<#@xN)!kfpmr-3d4vbSO=E2 zr^>~s?JU9}7uF;TTN~U2{r4FFk{#et52ya{0RBcti$vD+UT%~7HxIp=tDg_6O3P!7 zI{*tc{X4Ilta;2hd-R~6E%_z#oa_S7t9oVBH2mo}R*7f*<=ykAU6Il3SO0r_9oWDp zk(B@USPWxPP;5_c+8+fUk4}8$HIvIzFJR=}6zqDlZ#|kb)qw6?10R!DTW``oq0H{& zfOlR7X#FXOy<>FaJ=}pd1JXsSUc8Saf6^d{0z*E@Ee2l)p9f}ba}Ubfg#bFDTn?xJ ztts7r*_10Ekf&SZyi+m%U#ysbtP{_Xz#~ASfKHzW(D24Z!eedKKj)C;GD*NFC z023|&Y>IOB5s(Kr2clERm6fL+kpB4-O-p>FS}q7rI|(R&1{9aE$aNpvj}Le~^Bsnn z+r1Vfign$$2=OPRK6`fYqG$s1a#>GMjhYcfCKw$?kic_EZ1E+wCa@ZF@M~;6q zfib@tUDm!1z3dl$s*KKgzIlE!sCopt#tJgn@U!nH5oRO$3fvXM)oTS&f=%w5IAz1` zi~4>wCI;t@jl2NWG@VGzrR?f`l%+Ti-m2x)9r!fs=He;jB9Xzh;IA zmEnUMOr>}180D$pAUYj)FS22pW#)waAM6~E<*FDh)m`MHbvdulsxnMoA#?x2Su1Vn^l?Qi^GMh zAJ}q8)4=;m>@cOh_rE}E#|X6{iBW0g!X}q|CLk!Dwk|H`x`Ko5q2}6tsm8IS-{Q`phY5T!<+2s(N*nkI$S157b7j9KLXzr#(|MLP! zvcFP|T6SF(JZd^9TlP8jd`&i>(wo)jN@lfHEIIvEkxL#nRS7#qU$1Fw3w?t@BwRSF zsrz1V4G!}yQ6a38C$-9#j>$@6k{TRYC z84aW3Ld(^2|9cJ=Bs<(ZCq$9+kV~$<1u3Rgbir^OD}bKN1R<|4c$cbp01H0Cji9gr z$}XZbN|15}$j5qI;~qx44@FzZa7DywP1qzxjkY@V53!pnC>>dBbkOE+I2>%j^oNGZMo2lt<_LudbH~FxKNc#fm2rKQAE)IziLNmZyKJCG%p+!K^J|#|e zK2fuiVuK5hu<9c#f^xtsi|IZfJ_Wm0vq$$`mG8NGtN`4X@wNrPVeFqtp8p%9M_Aw) ze}7$Z->q5n*3>2Gfl)8?U_$nAXWTdcPMv56LaWlg;_qav=i~Zz`Szyck9!5ek5p#7})^!w=iMaX1wME zS@>48>2@P*_z560-ai>AkXYrk(CMl%NZ!`~fO$Slw%pr-S?JFjp4jNjrKkr^eY@j5 zG^|G1#-SBbad{TyE6{ox#C)icUbY*+;}S%x^e?Kg*uWTc&c8Wpz%pr}qfp2K1$~st z**pb?Z7%7&Acg{WX|QY~1}}_&uj(@yt%8xH^eUT&2YpMnZMh3zCB?KB;**W%r<3PC zE*gACqx0QXN;QoGWQ(T{57~42o=4~Uw~~UVewd6Z566x-p1I8{M`gX}F*~vzK$yq? z07GWZFj6;?9xDn7$N%8L?6B*KFo^#ZEYS6SdtpG6&QJl!&RefE0<0Iit8=^wHcNGV_fzrz(en{KoVwgeU;@`C))&_Z{`_)3H-x zRdN<{F2~1EHR4U^F?zy74pYOLzgV^eOD?Ug&8rygC*_sYZ$!t%J3av9>^yATaKz^E zNZwQUhRspvU!Jf4t1pBd>C9*zX?sb{{EbCQCQH!c?1GITh5NaJ{N-+Ys)7umJbTw( z30mBlwv<9@7k3+O#}rwm3zj5-aLht-+BlAikxO5J@S02hbQ;`yILyhpr)KSD|Fb11 z(Oh4epYrpIRYK7hmdW_?Da<{Ef9`)hsQ^dhW*R2DJ3j`|6WgiVkzdDi4E7V8bz=Hd zlsvMI@USoU6l&M+k)Wk)s|yrVx9CV6{!2NMtbt(nh@*0SbT}?4YV5?SASQ&Kmexor z4BXxfI9WU2PJv9A$F62lN>=MnV{93K>$5d_E>34~t)m3UNags!czQ!3YIW4 z$qstU>1{}2GkzV$Icl*n<7I(h0qy$gSus(8(TI-_7!^lT5y-NQ>G``mW#NlphW*Cn z{pzF82dF8sUJ|1fqES+jsryWDYUA_z?BGWJqz3PY8#e*#waA6uK zS)@5ic?|2WsHP1g&jy*M-+^QsL$gf9PJ#HyuU)(aJfPbwM)1|WwncjU(~a*lck2#k zIenPw*WtqhgJ%yYY^=546DkDAdI=Fw#BK}zQh=y%wBWJLU29%}{ZU#Eti|H?yDB)O z6^`Z%d>^X{w5g&iS=+SFREvQu(YB;Z*#C;nb50wP2`1N8X*iw{Fo2pF(1i$n5OSHGdeFJv(b*eCZ{gR2sL$V%5=)|PvhMgz3z z$upUb$?x}>JoRp%w>T>Y&THR^9b{l?14_9?GPqFGELn2j#3GGs?Y3RP6JGpRX};J; zY~-}wGSk9^g+cl)C{ciQnOs_ma$#nT`OHUvRLM3ItE2VKcMb?tnpR9Jagce@r+zs= z9mSZRGYVDSNlM-*EXYWS6ukaz+$Ph#->8kLzCkk~`=1E6g99Iyz%yIX0hY+^=6D-S z;v~a&w*?u_?U(0JG1fQ4mYnWljNs~~8TfW<@O)*^D-b?6h_Ek;=`o?2|2hN`VI~X14jR;X zB<@Asz=Q}06D-fYwe2$Xk9&1a?V(`B( z#d`@FnH0rySAaZVv-P?i;b=-zq2;@YAL6F6C#tn(4K1>-hu7F;k`c+36g$Z>x zKGp(8DYLGsNP6a^9EKy_9(g&Dd_`DP0UT6*hXWrAl&1(s*?e)0*5}g`!#>b(hV>kW`N~FdofX7 zriEpwNc=weiZsb!+}zXwLIDsN1nKJPHI&wD+P?FpBWIym!Y=P}*q4+6f-GKr%!8b{ z@{wD+b&7A6Hsp(Gf?bM#MSg#{@rw5nSG0?D=)cL1012S5eyXdzcoIFxMHX7~Gb_yq z3YZxY8U%#ci!Z5Y#InCS1Da#JPG#P)qr6yq@4s25X_R`@G9C-V`Ux55aRuS1)gH_I znV~q4&zr=4T`++-%$USO2|JJL8Va|G#t`>-vnnjO!y08}fDOxlI;f-!Rj}!BY)Ppv zcpQ-Sz!z`Y-gvr@T9{N0Il2uj2motgB#RKam2(nFJTyAd0(g^-_+$yfe@!4AP9M@ z(lpcQjpS53Lk&vQbCQJ~S_r4=-!#p~TUY@)(?Oc=2%ku0mW6>ND?b2Js6l2%R%sQO zxraYS2{?X#j(Wb0GA6TdDr5qDO3XZfoK>BL!pcRdRUbN)n6K=;uSOeqBCmfQt2z$L z8}ralUKAcA!XVB(_HhArQpX+;;wFv~&rhsH;1bADuvFAf%UOm3UI3t;Lh_`rt@<%x zJ~MxLl+TYC^NA;M2x*!ke@xlTawILmcFCvR-11nWSQPuD?;&56J^wuQrQYsxeZZdf7m%Yq+FJB8#B&jQW$jzz%C>XAne^FYEqxub;+SdVDB^L zISy$I?cn$_FM{WtP3tVF{X@_RWw!b`CTT=A-Wr5X?-!S5C_SAHO1=xLNp|x&&|Q6( z0=`M2A^9>Px~&hv;)#A}PLRTgd{j;45q#WJW`^U&q9bC1q+)ulz1$Y0u>9$`AH37` zq!`>+gOeZdWVCDO{B8wNXT5LUY|toQm4O^T*|beC3RP*;exh+7TpWupp9`mDYyD#Wecs&YR#uq|A4*GX1FzZSEd``sLHe%VbWL zT6V)5`49+P$XpQ^B1t)$j64^jh{XSrh2J^<`+xyE4}x(I?ieSM;Ep?oRj@_XRW6;N z6@{a5U!d(|2W8$!vgPj9y0+!z*H!Nuw8^G7 z%`aF2v?}pI&GSmd?WmpKxNLGXj5Q^TN4E085HcLf)hdVYYY40xB}xPxva3RAvkb=5 z?U|T8vivw!v*uGJ4klkqSEt4;xuM!RgL^U%N~JbT_&}gf){Js*sT+Y`E=kN znJ!qKk4nZ5=oB1Y43DwOjZYd+Sg`GA6>i2wXfVjEBoSrA$#y&@oh z()J9liKlAdU?lmW(zA)H&S1nJ7dU z)4(Via_c?m4{Ij&=2jSgJTUt}v_-KDR*LeN$@w7>U9O=xkTs}y%cCKIKi4MLsyL#M z;dAq6H81n=!NSbgR7``L=ue75NR7>u+0g@>jUYv7>)4_sh3H!&oWDH?^iw0$EaV~6 zBROnsJ)zii2&jWbQuemf<5b=VFQL{ZSiTIKaQ*?ewK@BgiZ!)?Dl2&KG?=S+V&5&X z*syovny4>)h^puhEz6z}t5m5bcTLjr!NXBN zHIkTl7en-Lz3~Iq9|%EVl-uYD43`QDCGq!f(25$QDncTPeUJTKqO-@~6cNG{%Z$TA zS_vEjs;m4BX}-8TH7=BfOq%G&S}2A3le9?Dok+XXd;||AOZL$jE8A}^weNkwyf{`Q zgSrqNN>&u&_aJyPk&>514nJJBR$W&S7=^iZ=!0_dV2duh&0Us92!>Y{Exx-^`Y(P6{o8{8Qr)~Ou zYj908yR>??kVxPfXzopU6cK(CdDR-*8K~{w86*)#W4{ed)ID2_qa5?U$UsD;RtEZ6 zP~oN*sLe)ADQHwSPeg~eX(88xi9HipKLvK2Nj4DkMTjs|M-nW*3$(+HBg|ZMhwTOU zscZ?`vZt)KU6Uf;O=kl?Q<=)V&ZK);(R9Hrehj63naoEmM-z|4uL4Te zbMr;wftcnh=XOP-T#Gx~KWMhodD>w#J_8NfR7AA)cQms6lRn1?)g!ZUTZWg|{vb=MHobX?FsO?fDGGA!Ti( zKEAWuAW@VUynLdJK?)1(eczXPRWc{MBYaXamK`*UBmsD`G}z|RvF!q>`K~O5JXFS` zTP>^cfCuF=Z|Ub7JUQ)(S*&K-lYqtqzM^uA8?W3SA+gYz8@ebD#rl?wr9;XOsIg!3 z6sd07gjW+C0>E?6i$AX^Vz6w=<4HX0+c;CXw76;BO+APM9gH=)efa#4Sg%NFl7p@kBs6*kC^`z;yQt-B=dzum!Lo9{DiIG-JmiV zgLQU~?5dFfkH9^}Fma)gwN{W@pxM4ObsnaX{lF4Qmb<4=qV(5;1!x>+6 zjc=yIv-5|1tG@~8J3*z6t)>$LDyh8!m6}#F{Q|4Q-RXUaqswn2J4`7Xg zj9x(aW6BQG8Gx=HyN);RzISEjDlB|FJb}g1ylfrFlB!e9{OiTr+ zW=wb|&}hw=vbc$x9X_rGD{O*@#Z0M@8F{@iDtTdBh>>Cuu@ zB}Vy{q*9L&O|H{%@nIL_?S%60Ka5XN1L8LDH85wJKm%rOtj4%!ZZeC4MOn1u0v;*Ak)a zL^ujWwC1iT6%h;RK-0=2Nqe!zF~3ql0YnR7BBCXJ=G$?P!50FCm`LrLp;&aQAw2Hf1Bnj$SqP%|Qi?z#IYjkgOPZk~+DD2!`{Y-rAxS=czq{w5EYsAMuXwO?zUOuum~DK) z$8lGtgpVJv6xRqdnW(o?0~6IGZ~>z(-}3&4$fi7xOfS20@kp#ZYmZ%DVs`#qS@+T0 z_tW8vSd=N$=Ikz;${oUytE$ZGS>`^DEvK+a^KH6PAWxE zp^{Pm2E`3iQZV@QwO6>MmlWQqp1(*Sp6pJk~TFU zErUL9TK7S_|I`6U?Zv=qn;nAsn@$aT7H+7 z1(rOJJ7HAh8zUjrfcU2u^#-Ju2JpHeMZ1I&2)LZ(#EypvQ9hN}belx01u7vQ>jgIT zVCg&~ES)!|l9sb)v!E2j>E>f$NjF)wJ6#WFinEHq-6F}PIT(@who-5*V4ZW{$7QPY za}LLv55=zy&iSWN&``OMDP#Pw@LpHMx}@>+6kA~6Fm*-CvR5P4Eu&Qp9h42XH_pAmH@(JbU6U6GRiDR0g9>5MUqmGYAF8+-|(#_4gXU5^zJubBCo zg#)k_O|dtcoHBvvToAOt(4D_V#6pav%_~iLtu~O6bF6o|Sb2LM3>Vz1K#sGz4CFXI zMoBJdu-YFR6|>V4ht3^k_R~CjcI_fCvmB~lDXA6e*D7$ZERW|R+%Wap-2BpoN*Bk8 z5Jc=?lYhm$42?f{cJt;t3!uXnej5mKlxVvYAI&YlOsma2Pu*p0qgL2o)8Dl89I@WP z=?89&iUuqovdW^U(ge_is?lS2>n{e*EGqD6LtIGTo>O^5g;Gju*a+JJ1Jk^2t~Cmt z5Qvf{9W&;0szM3@1^L`i7En>jLnVpcqDUwr(d7=kJHQV92vO}po6Kt5_~HpTUE+3C z0n;%2_gD~ghA!d{(RgURMa@+*qfciyzPSOIyl&yAGv|w*My9iwU&h%TUwubb^jpJ~ z$?x^ILfa~SnmZC1n(5^2u?{Bv`ZfS7Hn@W@JccQ7h{Bd_^qP!oG!RRN?uWKaeNURH z5M4m!%o0D>=Xt@603NDVmIB2MI+|}NUAd%Ct<*t7`;8Cv&~{WYS}ri+=xFWLv$_72 z7Fqn^jQ16T$V{!|QHN`Q%%7Hdd!4})3n06*2QKpc??boFK+JNQh&tfDY98o@Srv0F zxIw#X-s<-ZmztoWi;67zQUkrY!vv65jll}9s+ z{wcejHYAmn-vj@RPmPp2SeKOk6zI)j2M#^T1d9&12Yh@V&|?pDFR0DL-sdvfCAKUT zKfGx>4a&>mPNE>$FFZyGeSw37((Z^Z^*io>@IDXFiEFVq@I2k(IQc{|rl5~oWh=a_ z+RgC9$97-yLry@g;Uicpy2M^EAy#H^ar-(ViKRGaHDvim2$(bC9;RGTR&r=*V0^sb z?jIE`ef(ql5ViHIy42b$#r$(T`oi-SU?IN2Aknm!VQn2+vf?#8y4RO#&)75j?DF{D z4}a09sa7!Xm!!t!-;4MX1e}jR5;`5jB&JIW=gsI;Zf8K-|5B@fvGJvKp&0|5&a5m5+Peng#zF{{| z^^hx}L#fp=Zcru{5BWt-@u7N;Aso^New0x3=a>M-t9LtQjMSFIT!c2!DF9Q_Br)ks zUZtsaWHOsdfl~@ziqti&Q?l(~$GM15yZh9ZK$fJ>Bqq4B5wnN z-sKhG9Bwj?=yK3ni3vZ*>YKdvAf251ahfl^x*{Lx z&GicSxkiHui&eS0$eq;k!90s%n>{9eDN2E`LFVZ@?LS{#`Q%`W4VBSVnEz~iq~6nL zuXDUOztNi86eNSg{8eyZG%jD%;iWtf+&+bgWAbvmZ|^#n^t?*y7vM_O7yLCN{)iEo zA#3?NC7R3>ds3VTM3)PB^ok!NM2O+n;eYACP8gk(L73D`%QLRY_JW!PB^&O|9jkU0 z9@{Ty#O2h^;*ON{K3VLLT*YBJJ8MPbN^9AyzBQYyh*q={vA;sK308eF0%bM$oeyj- z@`J;@aXVogCp<+SICPD$@ZWi24y@G}lvD>#>^)`6cHVQgAC>8JFtuPG)8+_pJ?e$1 zRBJ+M9@98~Uap_By#8B`f~jpofMykF>5F1&Ev6SPih=>TYWnQ;*gCIoFiaKLH{>Sa z_G_Gapm5Mx>sW-CEmYJpsg(LFr@IgQTYS*IeVTu;g=B-4x&Y9qX7caN?^|L&*hB@z z$c>jMWw-!CtoP=7wc!8@=GUBp)+9T!(-pf_x+{*&d?Ksn@$Y5Ow zSF`1Y5@qs|iuIPI&6HV%c~8mGnc=iyZr!)Bb2zFd_=T)$D2|*)zy7+4RASCXp!Jw$ zUMLhbX0V)0&BpyVF{NkFd!!pSn>PL$LSPG!XF)84EXhbyM4TU;o|heb6ZJ0^=i{z8 zlAI`%D)=@+sQYpQ@eDvfDQnL5mXN0OX8L{S7cfLtlvWDg*6i9ySaz7TmzI_a117fiMprLtm|k92o4KI*L`uJUEHOV&cnMhTw(d|Edo0qbGqQV&&q&kcYl zV*jc;ZazRa{F&Z)_eZ~dauP684y;he5rDYw`x2vSU9nPjilrsNO>j+$oY7-H`0e=*a;m{I)L%qp7aDOH zo)GQ0O~Ht;Ywy{%UdA$VN48`OW~FN2Eo)s;#W@)xt>{GH^>g#D-_Tj+O{O?y#O7#} zGE{tAD#V7OWpk(C8@(E6JoAlFf-CB4B!exc#TIi_Le&O8BRx}C@7v*g^W!Q+G7?!! zBIJU&M*jVcWjT)Ft1Dz!;EG$Usc~~jQ?m%Lr%g#|AwfRq-ln)QoGtWR*@l~S1 zS9j=bAvtrA`&K~Tx9YYvtiuxCaDB*8uA&zR2gkM(QzA{=*fRci>@-OdZ)T8v{tc5P&Zkpxb3kt<+a)eO|U|USy#+NshxvSdc!SNGvVcNgIue8`_JwzAmP4 z)hMW2>-P6L?TU|wuWVB#Tzq-rhqGTt4=jZXE&uV0&!3)S^!Jwmfa|fEMR5R+(-?#d z0U8MHxk=W}Id@d2iUsr32$#9z_2(}~wetz9padeamNv3{0}=z52R=@&1+Ro?x~mu< z3t+}Q@Aa!sw)E#|oQnL5qPTXjNVG4(5cIo2=`qRIF8&c>DG3Eqf{kz+G4Ts<<43`0 zB(tu+yTbSGO^4^ha=bpfFEw+X8~eXWISs59g53Z^>D7Vj{XgOB8osIft-I23mT@gO zJt*cQh;|iaTqhX?;FE6toZgF;6~OcUgiQ9FUk@hc?bw3t9fIy&sWb{>beJ?Ej7zqi zq3GSJ3A`vg(_^EfW{>dJH_Vx~`{UHPG4L2#JOzQUwhKjc-ydtxl&xqV+q?$aD-()p zpPdU1N7k>LlRq{^3^!n1Q&Uxew|micXg{k(kubQHvAen>({^N7f|KQZ?}=BlX~%}T zUoc zXI#xldi4v>_;`6;Iuw>n>MZG}S^3BGY0M5$clLC^)#rsH#F=?iQ|&5sQlvOp_t9=b zpPDPwVTb!Ka7`4z87Iss2vMc5PVuRnql*M|C2*f)j#O5;9CNr`Z5}0NVor2e6}QRy zLUG9RXm|G=h}c7OT_=?I2-C&A9r&ohZd~iTAX-1d`~%XOgXsryq{XP$Sqs{n<=P7U zD>;7tT)$me-*ze!Pc|a?1Wcsxw*$uJ4@>~7cYd^RSy@*ocW(OKB8|=JGCh^2@|9s; zr%pGG6Kk$$Pbin1x@Z|Zhk_nlI2|}6`)FzrV>*!uiP2({Fv6nGZ4+`cVUy;R?jCbB ze>-r&ygO~Luyx=6Nup;P7;rRWq51WV@g0<~K07<7eHHU48)O16@oK3}&QNjeD8@P@ z6o)trQB9t6lS^bkq?zGc$26NJ*95WAs zLuS1QN_%&7cR!X#QYmD5>=)8%E!gNkrd&n`3*{z1hLa#Wro0$|xsCkjDUe#7{;B(6 zo}!?7l~cN*xRdt#p#EpXDRxoX=vM~@wx<&qQ|}g-t>48a>IuiDkXMyTqrWsSK! z7I}=SAG;9ck}9Cf$Vgz%h<4a~IFIQh;JOMpaE*%WVBeEa$X{r^Uo%uF?%8^IS85aW z<-iuYEj;9)AcuY&qFo+1!&WU}EJ$ugWL!@GL4_Y_v2hdF07YpPnb>VoL4Q(^`zNaJ z;h@LZ9*u7KvE}5D|!W>e*n_y~GswTv*xN zGVWm-f;1H#y6k&{cboy!x^HBQ$^2}kP+a`L?!o>M`DobnSm*+xzxe*3?yQu% zzvdy_Gj;HH3FCM!*^c>WAP?a^B472=i&fuwR9usAE(82ei%TLMH9pS8=51>3fFu&$ zlFi|dMW*+UPqlv}=ei&6x|4DN_n&KO5M9i55pHLS0!#oS&mpv%|L%@LL5fojo!rQ% zYghb=(z3;FM>wX+J4q-6>?nbRUiNtra9tRdP*E4%A;3}G{{c)LxNvrwR5(4sMK+}I z&xmKguvNBS6_({xN=<#qhxsAop`#(($mPEC_c`SZ$gFLGd3uVSdn{LDjYBF}=~*k> zEFNCUb|BTt2f!P31H`vx2sS!kX?e&N5+ddfE6BwpoaRfb3$E+KUyzFn1q2MG${o&5 zZzY*d8voosc4N(jH6vil*M|$qfeDIfWnwFtr)H>u0184!jTNOnTiO+>XOvpa2v_os zpz2q_s$kjyjNJ9IsV@2t@G6G=ho#BfO$nRz9F*vj#V5r;i)evjn}IE#%S8r83ZhH$ zCq_vy&qNwf@U5bkngLxx-1%s5xXbz1Qk#)UOV<&>Q9PTX9@IOr9|#KUC;JISYdUQ^ z>2~Wp^QfEe#ass{gK^cS%!T$Pst>g-r`P;QLOmibJ7o}TbkuuLlT-F;eub)Oq?YY}e@{b$#IE4)-mzS)jNV*$U0GIh;9A z>^srFDvfB}nO(W@$d_lj(+9t6mjg!6M-5TM-tEr^!87HIu~LPCAWlC{%BmlpiXm~| zm7I)?(LX{ynIrD&3YYA7TJ9=)m|v_fq6ru|*?p_n!;R)NyKS(}R+(;`2?-jb4g>0D z`$3a9I3%Y1*%FKQ1@kMxUqtADd=JTUVOBQdj4s0TXxA&cD^;eEje0AF!*NcTQR} z&H%3^EmrYU(kYfnGAjp}rW3geJn(B|(k~wfaTo8RhrI9qtTIW|?mhp?s5nD!-B-!X zNw>Io)?dwq>$%Cwp!vRQ;TtKADOi)lO@NE<@e0W0FqGqXteVq(O(EO9*Q|2qiU}8Y z+((Unerx*HC+dBycgjBN=LDqx@-R2}ABH1t13mfq=1;YUJB=QnoL5iy+GT5nMw|E& z>J|5WO9c|P)XJH;BW??{eJDbpnR~ed;yAn;&EsEoAG|-% zi1KJ7*Dq6P=h8f~_%%OkQRwda$F=!p%yr1QRL`LBQ)Y^E>-clod|NZ^{9w*pZ<}t> z-NO9Lk@kUu%WBJbDd)ZYcJeggW`u0Wu#o2o1lDGa)u?9NjOG;mBwuWbF#+bK%o3qO z(P-32y1=TM>d-QPbd*NcXMCby)FEnf=)>6-vajxs_@tI;mgG&Vv}0TIDMY8eQMO$` zd;YYzpmn2s*WH8mXyswFL2vhSAjggzLUG@)%FgH3A;PN%U|4?h>7s3~C{tf!Cg?@C z);u69WxaS?B=I$wj|N8@VaE^C&9Lc$O`dOCF73CWzUOG|-UK&^&PWGf;8c*k7yXex z`J0BWowXc}NnI9rR&irXXbM%Qbdh3a7qIkthYgqe4UG7*oRsk197pO&5Nwjv#Mj&x zTp*z8Ixeod23T$Uw!anPdS@1k?7rva9RhA3D#M&L{oSLUA>`o#+Lmqt|LIiyIhC^4 zvRzJ+|1lSlLqNz7NA_ywj)IE=Lk6=nns2~>?7VlrYhdus{4@6j`UNX#ztSv3dJYOzgiXUNnsgci>E$}*H zvBUv0VZVzkWr1om=Ap(wVu9aME`F9ZQpM zj=$`M;ynhkEtf}F-?yR7-_6xJ403Pqx2-*(P0^DJ=55KoEx=5yaa=99wio-`{8bR6 z%6U1~QOS2N)u~)aJd;^lY9m+}YDAcba55=;TenrL%-Xi!o!or?bSc_quuYibRqV2) z8YwhdT^Q!Da>FapZX2l!<>$FSW*EC# z0sLl3TZZM^U9F%Uq2zCIOo_fersX=|MoiovB!*jk=1yoJ<63EFRTVB98zXK$xc^XR zbLo7&gEl+W-8_9$d%vKAeB=)v=={=7!e-PIH6NM5&WH5OVNb*7MdP8Pqb-*Ae?hCI0n%3`B0 zcSg3GW=|<^h@1_57;$bHHgC?=25cd7UQLQ{jUZb6#43e#HiP13Z%o3a^6!w{$>GB+ z-z;YoM99}adn@nIu`8y<1)CD$SQXB!#_}?L7r=~|wGD@?fQ)tln}Jfx2TfgXbH#_| z)|}9^X0@k=XIi$uu>n%SS<#2YA9#k6(f4}3G|`x8zH<%lZ0?gbbowO@!}a>W^YJ`J z%fHLlUJ1PbEo^lz6(pmJ3{iv5{ccycGIf>_>J`(_MP_O^1$d9b@4dtKEv96fvc#ih zpk7;iB;-UGQJ4iOFH}PTvi}gK-E=CIbWeqRx+4fw8sn+~H}iQtr;5ZH+r=N*xmaf; zUaoM)M)phFU|s)V?I%&oP+s14h+z0><`PJQ%=adV3TfXa`m(Y+^3?*7Y7IxN{W)Ep zN?VV_RB-mo7#TGM7#}vCHBKX+j=@F4#;2nt&xdt8sdW1no!~gp;Jw=8+O~Uqgv7zE z;jqAm0_Dx#+!6Lm`JoTou^`!dwTvn(EmFUSFN^U!dp)u)HE6K~*AzvYhASWG_Vp}AeK2y+A=_-Rk>9%KRg2kf_3U$jUc(eZ9h!1;bnh7D7qB;8 zY;d)IdAKX!iEe%pU{&JKWB3QG#4?@mb=1c*V>obv!gwR(^L%HX`Co0zuyqXSd3U6v zk+s|m;6;E<+C$eZ=Z{dPI8l!Uzgh)fF#5&1f4WFk>qg zc3N?hWH|-YYIMF)evrII?C_S}f7!&~&W}nvk1=lixjKnS^c*3YF@5tZWB=g( zk6B{k&e<+V{t7K`jmcLbEgntzk+j6HNI1Hj@1R}59@LofZpD1zK1Mzn5H80a)cBq}0k6h8bai?huDrvX(fQaH zonmO4(Jf4JcCoVjaTlrL{uy|GZi|_Q3*FNw4eobQ5iAa^9ysA2xwA+Z*8X*OJl`Y` zIax)2_}0H73jRiY7ZOB-HSz6^PH_?6UqyiA(rU}j&p9VIF*>nx5zu$ z5347IB~J5&&L@oy4JWN04t5PLtF1R_-q%k@-g7$bwR0=M&IwA>atfC zQz8v_gb%Ec>?_F;?RksL{quw*C(53O8@GEDqoCdHrK)-e7DyFLQQCf*Ui&7T2VN7J zM9dMGJo8R0j&(FZTn5Bp?=_@APLD-1?k^7iGJi2>2?Q^LV`R+Mg$t?gExDF@%m$%f zayf3?QIfdPpj^s$ioY@(d6EB1=xb+x3gyo<0!&=S@O29&0$n!-sH~wUC@Yc{0hMA? zJ_{!Vp}LYt<1!c<_sp1pg~kvh7b4=1W<=P73fgdYQiSi)n(sa9n@D5%zJEm+nspe> zbayEj(y{2;*U1`=$A9pFgaH{uA{TT%uj&6s zfU9gV$H&e@-F??jGCUlU>S)o%-azfRZTGm3AzUUYr%c|xVSGvY6pznhyz!V>MI1mjIQ+>SD+%I%j^h%`JSK?we*HF~9u#iq?-fX32qh|+N zc5Y9=HF8TE8ZRvcGa&OMI|Cc)%cE)B-vak-{T;{f>_?Oe-0v2c_Y zc!oy|Zh}2S))m{PKD9YBJL5M6kE#)(nj!_L@?j<)3Q;Occ;i+1D{$Np`lsoI8`5)c z1>e8VmK~#={`+AJD-wbDEpKLbR`F+^Xg@ZlXojj-k`Dv6V-#DFpuZU=B-ZURIPM!B zrabAkqfz(hem~ilT^F;T(LZ@ameCROS}%oBGB+>cuTB=GDJ>4}fwOxHFHfZfT4IV9 zLnk^CNmUMzZ@9dr^e5wOk!ZRbfDWA4@7PJ10wKvgDkal_A~;bNpS}(~RwQWQL}407 z_kiR~1dF6E55W~0<@Zu|k=J1~RGeR$@8`5W8^#=@<$tMebKWO(n9oC793zKDGC7fW z2R4LFP|$2O3Gqm0u8A>2f>pxX8*>%Eym)B-u{mJnQYi_K(@({6N)xq!2{p7r=n6!c zKDkU7h`zMvk@zV+VC#$yR(3yEODV1P<|?@C6!>Ci$HBA{#=FklB>YuCy0dK?W6NCEs zr}#qWG3G^vb5hyY@)2oP?nJ$wL5$G}DXJx01a*aXjLQfXEkT__Xd}^7jA?HGjqV2m zYXp4py5bK^==68__C;R_CDRmq3Uvi`>~VV)-^O+k%>O^0zQdjE_Y1pKC3a%QmPFCo z6tP#VB5Jg>wYS=vDq1s$t+j$$MF-W|vk0PAZMDVTo1%F0{ax?-{so`wdY_@=W)Q zmPl{-HZ#aQy(*;V>I@(0^QqbZf^5vvB|W|7Gs3$Du3ieM%Q7oMpWAA}Y0yjtI1~B3 znWJ9>Bb4Dj_XwQ)O2}idP8Xb#L;ZH=1c!ybr22yjytR-P=0d~&BerM8#w8<8G;iy3 ziaXlmdxB~tq+P_uhu+%@Ex9u@k$+TJekwa0X`AA03tnT+wzOehZvJ=dpy&#{k3*Oy zqAmY6+{Ba0cm0juauRpowT}FmY&FFS_Xm~K@z8!Yw|#5=)y|tud{Z7p2Yn=&@&{A7 zPbJT`<_&G@s|MbGRW)u0;A(!^J2xcau1}lF6FE<6MPs)h{;LKAir;&2=*i3GJ;GbF zO(6+!P<`PpJrKCsCnQTV%vO7DS2WdP)Rr7X9Iwye0#eWq?Ejita9q;en<)katd*NQS&H z%k%yeeJ2>yzNyhi$ka)qDCS;zmNsnbKr2dIg>j-mEA}&2djy zFUQ7$_95GR%0$$=7xcdbr%YQXussZJ#p z)|AiVa9d3o+O#FtLpk?7y*OhD?#1ZR=eu<>nOfJHcc8uNX*D)&S37e>6wpn2k|k3* z-Z@{Vf#sKo-f6yZYz#XihnZKGXOxIo!Td zzj0DD3v=lSbF5X|ES>L$AYBj*Z z)v9Z%N(bY*?m{YM%*@{LC&!GXskH|e6amqNmn|19@b zYM(6QTmlEkasa0DQ57>2L7z67_g}U~*oOQqk$(MlEeGa{5X+xNq%tcTPv0kULD2vssXvAg+KzQWB3V<~T444+&W%{g?k zN_E}rk`U#6cgr$8Mrl;cusZW(K}5NaKlg-5ivL}?iS~#I8Qqic49nRa1=C`I)|2xt+=xT$w`YOS4!(sqv8bi~W;U@v;TIw7 zK)guGAsew)?NdV7c}KmqDCtnYfluSuxN%n>Z#vM;7iaIU6C(quhBpDo=D@SSewj9PdnoT;0d)R|TI8JQ^WakLAK z248!@-X_oG`o!s9Ppp?hk%F_JDgjl49J>W0gmA;$JEwt}0StG8yLmFS)_7nKH#-Y%PxIkzJMvx6rvu8Y~E+K1AF3ijaplv zkMuV$obiZER$dh$ImHXDgHdeE2XCFEcYdEZB5yg&i9t-3V zzqq{E%YtiF(yuapuTWh(Jhx)Q_F(8|Rh8zhcF!oMnq9?_U4mWh5`X~jjG=+ljn|Kq z$N^%EWszMuLxXWEW;bB63oKwLk#AKi&OL zgwE@+7oBp1{1u*v88xPYrEkLur$LV0S@T`7;?&RbVbGVXYlBZd_tK>`dW(ZTn`tdZ0&a&6^n{i{%ql{sTWl5LyI}KCk%h`LjDtFh@pebI&xE8stCY-NWeXq=UW#+)? zSrF%9u5wc;2(s75Z!n%xz4(bK{Jef46fK_(XcH@stt?F6fcY_6Gs`HTQcn_gB04~ldtJhUJ=n!y zzdLH12ArPwv7{8WI^5mjv>D2XuoxEu5I63&*uz+Y*$LV#HdYE}TTH3z#jQj9h0q=v zCl*VDEb3a;Z>-e7l;zl_+LVrERn2|6B~10mIiQgUF7=QRsS_xB0cS+1jcNFA?!O3I za4%X(z10XVH70_`{@CD|khHzG$-8Y?ah(Mh{YA!VV)(bIMD9A`IgnR+VzY=oa@oqQ zFJ82m8N@{L^E{kheCTd|V7o}{I^Em1aL|LU0dTgsSy{SgE&BWyN0RW@uAF zf(sUo4b9M0Ao`_yU(;%1!+1ST-qk*zVz&}tx%aLE6A|gTbl+PSiv@2b2D+p9AVY{? ziT6+|IiF1xXmNjgbsQYxIOR^gku513{l;G|ve+G}yPAHPR?Fx~Mj$<06f#%`d=+1) zesq^RsGX7Hl5sy@Sp`Ce3MzKJ+w<}*IS#O!yatV}S;2ayo;R9V*0cOFUdU#W zpYS??gO)#-$+<4hKY5Ag7E|#fzU{=R_yoWFT5M4L*74nXC5?`Fg(T&+^Es%M`icM_QsNqW!LYju~MeDzpNw}@&v zI1H^ZNA(DC!Q9d5-{gQxI@Jmv=4rQ_z1sQ}uh>!vA|n1^13p08Nby27l|*Wa-61jt z3g8~{jyV!GXn;uVKn#A2BsY+i4hZ*+xhR$e8K6GD0@rOC*E%;;f+R^qIyk6t%r;}y z0q-nz_~05r%yVw9qQu*XmVk5Rgq%MBWw~JKtRLQ9E;9@(uh8upyew_UjhCBzbLN;l zAPF3jnmpJoW7Qhg*zl9{qE`ry_OjPB88|AaltT!EFVh4J&dinMYK7(ehU0Qca7J>DM?Lv=WAYk=y9ed%2nv82!^R zeJSszX)RqfH$+plPw;`dt>wBub*$!#McI&bJ~{HV5h>nQRM>lRr&r zn#asmlEQs7-g>gC5JQ7i>Ee(r!EgyJ2G54WE#vot!PnkYEmLGR19!Th2AYd?#tTdS zckgRtp*bTt9m2LCZw^3Esyoa9TuW{TLzk>Vil|If)EbsSPoE{#2aZvmm zqcn8pq5gQoS}fS4N?`N!GW6AH=z}16mx|TTkxCdKOHI_MN3gATQhXtKM@~n4Zoao# z&bqE`1bX*@h}wcZ0ol2!bID6)pEB94?YDU3=o$@u& z3wbw3-@2CU4u?6P;1MQuPCYa$)sV8=sJ7DISzT^&NWp^a$$I$N&xC^t^Hw_g5MCp- z03eMz^lguaacv0kpP@NE{nmzNOKX>ckR|-2%h};Tf)-l>@LWlHE=PY6~bQ5w^=4gUp>n)zkXUpdVRsR!n7rYK5(s_A5VJd zTmQbHiPa_)7ND?Im}bC@ZU@ea)fS`njbMtqgJa=Tx|UWgRfZ=sQWy4^nY^h|w^06X zBYIaJt~+CT>dCZcpO@~q&v&r6;l78>#CpND8F%9%ycSRiUJdKL|CgE=#>OZQlr7BJ zSt2FW96u2tF;zvm*O9JN`pBIB^B+Q9lYYw-F>p0b9ED8w?Rc&jmXRPp?y=*cdThQX zQJ|`jt}j)u3K#MM1&742A=!eJsfpsO_Sjs|fy+v#sBIM2{E4*f=L;*fE+j7=5skw9 zl!g{5v%&ey2+klg=As^9gGEUKGSIR)en9br{ko9an)9DG<-O{I+}N zN|p3`{W`bnV%i?-X98G=ExfT$6_mzfM_ksvTTot$S4K0C6p3wS=&w=B>NR(Sus*eT zt(fXPHneLvTKh7|*o8fMJr#$bcOon?2K1!U3|DN@u8q%k&Y$=Kbjc$f5^ne6*haYBCSx(FYCDax7%u8^vH7f2nQP`J7MC7HqQid??#xjG8V1(8V^;+_flACiB8kKP{Vnr_xmJ7h zO_vZ#;vv4|AB4e(#_jK00cW^x4Yd(_sqmA;;i8yS-REEzwCtHuPQlE41C_qf8Pgqu z2)XY(o8HW}yBf~^h)6SyYZU=25({>t+Bg@ua6qdkVeg*M(h8wwjvY`PbG zsvoo^)y;O!2zdj?zp*UFQ*=L$d@Lc~FMB%AuJ&hmDlRBUO12x8DF!eled>zmj$~`W z1Z<*=x-Oot7620ZK*Ti(aL)mV9pLX!sE1GB^z{5VoMG#zOK7RVuX(()I$6|CNg$_&Qa=}{et}>RUgdq7C&Y;(5L;|_qpV2GHjr60ARL`%9TLW)556|5G;9X!*@eB$QDArd7$%>P`R!A^d+`lB~nMFs$knslkvnh(5vl4t!cbzp?m zDYJYBJoLc1+0yMA)&@=Se#ZBtt5SlhD@XZv$-?UQ;MAt4v?U=vyaW&uh?b8ksxmlx zv;J~I{?7^@N&@zs93BJ>Zr)}7IH@t6HHSl^|c5K*s4P+{9_hg|T#`gyyc zV*F6VoOuw(b%=WWOt|A)RGSE9dVVks4)I+UykkKR6%(wI&0UH}LqA_yHd*3>g$cYf zP}i%T-mDWsBkYohc9e3Hw`+8jjqAJ4!%u9sBP2bcy=u+yZiIn=UTMW%1DlmWPO)L? znuI4UdzuhmIEfZ0nAUD5cGv$Te@pi22~hru&TN@0sjM*6w&~togMs3XIG>?TUu8zC ziVX2#{2%KP!PWeG6l{xA3k@jDmO2lzaaS-0o;khxzTRyYRiO8Y+cS&)J6-8SztV#5 z?6P<>n_XuBcZQBaq(1*x%!K`e+~Uj{yGo2ZznilYV8337zV|M{vUkSkNH-ps?0^L8 zS?}tUxC3(digd-G@t!g#I%{nAC1T~gHW<=23OP>J+p-$N85zp*s3pAvk%VKfA{_g; zqR8Z{^U#pWXR1A{?yAiCya`bvGhir2mC~RKz^g;=)PjETszb(ju$IgwLs@(7H}AMk z?5(;9=B8+BKk>Mw(fBu__RL8I?G*3eZA8@HlG}R$RGFJnNU(ZiHAZqGS^mR%VRgR2 z*fHxLYbi# zxEO_vXlGGNv+g<=FUagTNBS12?cKNEK9Yt2$PNW`X+l=R=?@4@aqu$2hx8!j9*|nu z7k8~>>QaZzO%^hy{FUV9>rJQdrRDYvMOS97$oxq++ewZ> zWBIFO8zjK#1Os?~53c@728xnj{@SkhqqdM z2O(MGAGM~vXMdVsZrCPJB+u(RV{F|_{6x_irQ)lq#*Mty(h;xv1-*10we-(c+0ovM z8R34M^EfA>a=lZ>y;Q#+#s(0H5&!Kt3k(SS zeM<8nXh+(_QBF?W7n!YD_GdZlwx~dkCoL78IUNsq(q&J*DqC!oJ7GSlWh%QhL;sc= znT;PhaT><d=a+Kep{6RiuHr5WRbDzEspYJbt1&8rddT z&5jiLZuO@B^}ncxW6qh!ams^M5dm%IWQn0Fe+bYb`-r(=a{)^a3Aljh2~WE;LN-O{ z2t?X zK1a8bfq|+5W2Ca)N@*#x5GVqvq1?6Y=wMd5RP+>P7GG&hs;p6v_D2cuDQ#9o*G&QoZ_FVoEHoY96DMh0ZiP z?bF?Qd%}8Ph)zF;y{wzJq-qKZ0^j$v@P19{d{cy?YZo|~ZAPW_rm&26m03>uy3m#g z88I&Sn^Zj{Z=4=;PGYn){GoM|@bfh-?{uFbH9Bju$`V4Q6L61gSBtE+{Mw+d$$&EB z6M>QHNQd2tB%oC2ZvF1pY9f)yz4M@m;YQ!Q-J-u-^{d>DB3Bqy12HD zqAfzDs#pCC-U(4JcuI*%L`AeO)QAfdX6k|ZnRL`3U}PB?b3s_zqiR(Y(a}1ra7N-Q)nRR# zPE>G_knFrW+IgNeTEEjo47bR2SVVImmTNxs^=9=$>2vxus74J>Z3}hTo173?4Px?V zmGz05y0Z8p4%Vj-x8u*(eC1`-`VZ@3qL^Vv`s_QpdavEj;76cmzZ1X|CuRK5aLuN5 zPBA8C?gUq+GZbKg)%+&PYu}J&4(^wr+A#Ct_`9S=ko>qBF=@=ACIX2lgr8Y1)@9O9 z&iQS*t`z85j9PBajr;Ey`(JsnM;N{UnkQ=o8zkkJG)}x0#dA{M?)G}%!yu+>+dHGo zGutaJc(IY|Wfoy}j8yN@IbaS#^8$D3S@aztYw*9H~hrOcef;!R^>U zhdgkEm?yIBGp}*h;|1T!AU%4B=Znx!=^g}kz6v%M&fq6Yko*8X!`6d=edr;%K^0GU z_9K9)Eq{dH=+@Q6mOB`n!}&6ew*%v^{xVwY5s-FHlOw;7njq$Hf3YFuWWZC;I($Jl z5j#&X=R#W!g|2N@WIxNfa1Sz!(=IHfvrmH+t7J4jJB~?Dh;~@{^)4RoS13kkDOu8g zJQEO5F>L%tE&F#r0@M<$4ZkeTrL)#rs^z;EM!_c_H-eaY<5s`&=2WpmK%0 zYLL30d%g!V2b%7$YweDo-FZlFc9R{Ufe$uhAx^u70QgCc`l|!S(abc$!%)FDqCZoy zFE-r~y?CRwcA`_cUviFe&h%gFAy<0AO+TBSbjAOEr#+h?w8zeOH)7e1aS|iMbhwUz1wO4CN|}XHqw71IkfSK zljk_iNI0rgA$bhCRsf{D(MK|S#r*SbIf!HrUSLZmNFH7>+s$k2HpW~Rix*8n{Lrpm zlH^TR!n7)RNz*&l3aGT?xM5My#!o-Y(e%l=i;mp;_KYpkVeQ>)XP+uja{P-t_ZG)! zQ85}I1j|@IB*c9u=7|N9rva0hszownwG73evo3hlwH6RT(GOsVjau;E>21G+|dU*4-UASN-QTL3P3nnZ+M{J)TdflD~ax z>48IAef-e+W`jOAlE!A!&!107?Z%$QQE!A_Lpe|d((IA_mI=Xo7OhMcRKfFWil2^MwrdUj5uiabqHiN%#JPu0dn09hP# z_8ajnD}mWxRJPWjER}jnFS-;ln<@ixxi?KveLik;*!d`NrCP0DpQ_9Z7eOL+Ze^iO zLh;-`&qbw!_&rmxGD-v~ub;J6Xa;yx6u0l|ttP9(ZRN7&sJIx7a%8f?iPV0kF6Q0d z33xPOZ51V4m}|`)FshBxFE~sLE>Y+bD#UaPkPB1KjOtpx@X_WI&j{wJV;(^V!GYvy ziuw72a)|aV6wlp+*PsXGUw6Juv{*$FEz2*8t~gm8!#HsX&f{3EJdkXLQ@VHZ-TQYu zQo|O+J90%*i7XQw6$L<4zj6+JsCK+uUepka7>GxBM(T1IeLb7~DJgeF_mkOoD5O|pAPB=)gA=GWT?UgI@!>JuW>7O8MustRV#{!%72 ztI0z&KiaQIlm6|5qDqn61tsFXX9QVC(pt0m(nBH7SYE>kii1EY@RGp?eAZNW6N>m? zw3Y&o6|pmko|xlS(P`4ALp2~otEP+80n4OxL})X}CLm9H5X$|O+F*0@q1+H4PE}12 z!QMp>3nML%r*u~%eq=89o%db7SrUmWD9ac~3*sQD#VqNoOMWTJ;CjBpTCIy2?H3ON zpm-qR!!#h?^M3|Lrosl)RZoa@n%mD9HTZGi4<(X6TdAD>>fmv5Jzy-)gy{pQo7 zJBYb1;YN;hyFGV+B1tCj-8d#G09s+_j(&>BOSMN}l)(X^=^HQ#s+4)6_lg$QFwm@y zS{LgQs5< zHbhObN)`DVmBpk6?s0gyYwAga6uel4h(#a66bq0iDmD?i1CpjNd<0iah#KfTC3^?l z-7Cs#`=d{hh;Z!PmX&n8uin~34J>@vOtHJ-(>(=vnUFP0{`st1?spNHIxg$Lu;ewP zARWzt(z#{BKt1diXD9kw?>KwQV}?q$Cb)c%aA_Mi1gjsh-;H^W$B?T5B+I)KJVwAf zPKmapzO%8OnJ^itgxBRoC#9U~LM0wyZ`b1`YPl3*vwxkwLn+fEG%rTsSgkDPjw&I; zdr0~`A1RV*91;O9K<*?$hoMB=u~7(!d&C!sX8>X-ty-0814rTc>9qX*ZiGE7RwnW> zLOFCx0_lpkZO(WooXmju`cny8YMWP3m|B}Pk?Cs3UYNFyVSBQ;AhsJQ zzgH@Ze4<;`K*DrE?0Oa-+xIx2$b}6OPAGIMN?FZqml^rQlz3M`wbq3Ic?@yf?<||%bYXwT!nEWku5^)9c;cg}X(KvbeF^8j zq!F%AUAyzaCa(XAh|^9aW?cfwIB?&qdEn!^hQpx-7v(tTi_rEz3|;|;#<^GgCUoRm zW8n`){@n3_%t|0D#iu)OJUmrjV)#2vANd;RFa2TIb*I+q9`;{i#Kv^c@9ym2y1&{~ za%vcReSEN;PzD_7Oz1Z~7LFM){a|WV$d5EpG1f-M584mJ|g-lm~z`-Q*UqOh59J@|9qEPq7{~R^UUd(eqqBW)<7QJKW)( z3CC^bmu~-t;&nfnurC7ow)~S-m-S>3(<(z^w_f6H<*d!JsaUJX?5FHZK_xer%{MRn z=IqvZ9%$-o7d|Yf|DB`Q`eP<&zFGGbht7Q-?kQ0Na`ukDNt-eB>xRYz?{z#joKM$) z+hBCi+6YT$mBQm#t72=Pf{i(pG;Zq1E*YLl@^aGU9Cixf@{{veeS>3lv3%KqCiK0& zyP?%`ilpr`Erbh-u;V%p(2-+uVjCsS{6xD3#P_2|B^7;4l6U%d8pq z#ChZ|Z!Io#f*_St@bJ6?LGsQwHA6&&W{)OKLA*P>0)w)96(n04LeAs=MBH437CzSe z)c5IO{Nv}Us)KmDyxVf|q{2<|bCTG{P=NmS6we8bQ>-r27jMv?O1j)7plG*dCyN@! zRPUm|JDIxi8|at8wt2u`iryV(H}~5rCp zb-`=Ug`;&^xyum^hgjm$(-ghMdjI{eCMQS~T!D2D0f#J=g?S*VtssX+kCf!I2hhLmGQrc*1r`iVTl&M;Zy7Ur|QO;_dN>=dtOH-V7PhAcIIhY z#5t3-K?r7;%e%6q5yAXKiu!mh%xZv7k(F8S*x1U4aGEKB*E{x>Z^TVO_mY_!XVqTO z+89i+6wZ4~6a|a^{K{lyOD8$b4tbJ$?9vQej4FapAYs)XmUUsvU(CuVRI_di$_xob zR2cD+1k3&_zw*0Dwf}gvsYF|4!7A`eL$hY?<(73t=nJ>a!h)TP4{pIz-h<5~JE5<= z-6HZq_lR*UoL5Rfkb3Zw^i!^nJQ6OODxxvgfd@?xDNX$^BZutU)$F3mY@^CxHO;2- z!Fg0vP22A&VX$G?zJ{B7!^Wy|&iqsc(HhJ6KAc1Ow|x<*K0)QWhA8N&NaXZSP~PEa ziG20>Z2f-W&w%#}tFM>I##iqWSv51N*-(FNws93-ec}FNF_7}xkh^|U!%vQ=ZyLlP z|2k;cra`Oc7}6Um_hK_VuvzjKCWW@(W;4>R@n;_xYD27;(jRW`O&J{h+1C=KUlhn< zSTy|CIkoaEh>zmPfYlXg!2kRNx<3^@8vDU1{Uk)+zcKhc&<#ic2#MYV;uOsjNSrCe zp4Dgn;Z%C~r0nx?~A6pPl+3sQ>Td&OYU~S_CknJgi7$;oS)hB_nn&NKc%kkn-^7 z&$DOanA_Kn2D&YYQHw>LOv?cFXkZF+QDsUx+8*051w#{W_AY9B(a=Z1? z&8Bs65!*`wn)q6Cy)ubl7d`C=NnDC*hIJQs89bt4-+m4sI~V^l_b+7gf2E2*jG>}> zsvOjZKL>&AuW3?}*?#}aiLX(GvCD;i09t0s_AIxu`&k#r-jFx@Y|`rK^NUU8_jetv z5&L$E9=l!07PdIt%;DQ3!_oQjvT>;QgV+V{I#0<@^DGjt?#GO{pe;k$w{JWD{Camy zBrO=4&C|O~fPxPL(M;&FY}%8{=J?c8 zutix1rD*mnjywjBvD%5XiE93QcID90YI@iz>;%Z$rcsyBc)xwu+1m2{9jtca*XEVJ z@3~X2c*<74z2gw-Q2s~}RubQ$k<@hj!H&xrvIM1F0p|vTSJyB z;lVE_x;0muQ@UzR$QM_}p49w#m5uBu+?qW9M}7bOcl*9#!#=fqIli}ki+`#PBIeb$ zse{_A?ir9xcOy5w(GKYam{ExzbNz&AnIF4Zr?+koIa? za)5b4(v@p4@4U}J5=nwgK&jOE7c<_hx?kI;a$rl{CO}tQL5OW*D#;(o1_RncR3@+L zF6T)^a?K{xFhwgbrBri}i#|gn$u3>3jwmBPR-PWHm5Cq2&byZ%74G*Ja1a0bnojRK zM6J&Zy7tRo`T|pDAyAck1}R^etl=GDrstc+EmL1K7)`)I!nLw8O>uf?0Td~-zOYQj7tGaY5t$mKd9n!B24cWWoG zq`3<_Gxl-Ph0s$bcMyWI4&&9vk7xgVZxnPe526P1CRTXNgE{qO8;oM>Pd}*=MgdhV z^A%k_JxMQr+4V$mEMIZe*-X*Q#RfJc6YS-#DRtAX_&VS8@~bl|(H3~)@5Q3Y$>_6t zyJQ=$FmE}`!kW6Ke*EVKQon|N$8M)q{JoEwP)gX7b_*6TKAO}Ox`7kNQgH)lbIGL? z7I~jIV+s^D620=qG$x_;=0BbRf?|cnVg2^A+WA7iuq2ETK|4d3#1_L}<5Z7Z{B}Mw z^t`U(t&k4=Um^R7m+^wqiBmGM`6j^;`FQE_KP8|9pF-ru@E1gfa|} zu|n^9R*3etmLw1=yfT}b&fFm$q}$_PVbQ!CE{_%_7MJyNTBZxpM&?v5ft=mv)pTHwf_2DX`E+L?&`uk zg|@po$^gOnx>SPHSnZ#KCjgy)9CFOw@6>&X83n%p!8XjGf)e&8CrJr6N7Xlm1J?_G z;+5Sgi_2lelH9-p=R8?798$TzP^TuNMlK(Mpzp?rBT+SKXE>v;pMeh&c}3mFRMWLQ zzrfyTM!Aq+)euj}UyQbeEc{mUA*^Y=Y(mFfDshiwKwfl-T+m~+8k0q+0j>2GeHsXI zPYVT;J0EGrKW8R}Owiay=h%IT7oSm+K4IO(j7{yBpMaT{`gu~#OWe38IL3E*0HghlQs{rVsV&qJT-h zSgi|!M!6b`T2NIw(NaDnz~**gPqZW1*wN2}mV@s=7xRlX)4}7#VtUabJzw;Lsb1|z zT1Stk;&npi10GF`GO3WXq=h%ox5_GBz|@Ko@TKVm1op~#i^+v!wjF(3M`L=^+J2HfsQ zVXFe&a$rn^7baE~n-@j3;ez&_$yybq$~T>k*NLn%SE7}^OHn@hj;idA(+dgsM|8e9 z=O64vRrZaU!d=eb!!ixoH`92s;8pq?7@m7*$f%fzcE3K08|xf<`9g z4j&-i@y%(QM~!4O>cYyB61?K`6%egSoU=JK+z8j*s*Leo)DCUelO!vgg}48jNs@^Y z(U=Ow5Z=f>c5hl~73)dXf~U$g8!lU(eZ0gLX~G5GQ*Y5=R`34G)jSRT$s}lOWz}qh zbpL=T$7%&WpkW$3Vna|q_}IG;o|d_#scSn+>>0)Y?Ks_+4cqfJ>vI5U)`ZmzXP)?u zCiZ2vu2xrRhW@w*hs%|B2A5tt)?QTWU;g1=)oRQr+?6;q%5&8%zk=HA8^P((HfHXP zx&P!82yaeT_sh9N+iwk`k?#ZJz}0Rp!B{$V?M=6!E*<`2d~&pM%vw0~^8jXott`F!R<`Z|gtz@I6Vsr(iCJsb6=z zht!xP%!P=Rr;8A!FG7omxJsI2QE>|Uzx4fD99+1__j!Nj=w%YugWO7t_}=ne*v|kS z5>1?=SqSE^#sRc05#KLbLpmpVZ8$D7qKXcYuaN0A`#)(1rrP&CggPTV;va|+xg$Gue`~Nb&TFzfu6l5JJGx zWT@2A6qSJ# z!&PQ4J{6UZ)o~h9aOCpsF7%)Wf`DY^^*&M{pH&US98tj!%!mA`6?`cu;Fh_-9y=_vzu!f7+KosThpH=W-`?!7> zJSO3LWuNd#&DwXHLCzxDb~WyPPnU>>pA>D25t4byvS$=mF5=WO#)x5lOxnqL$8~v3 zdYWNGTCQX*I=;QWH>9-N$t3tZ_3gyMdgm;YNW%!=lT0xWQAUO10Qq{qkI5L?2P!x5R!jBxGRPY; zf%|k3wUSVK9AFO}0wHaBNK9o-Y%zzJVAwnZK5fs0Dx1HqQAaPRJ`H3FE*an>mM;Fa zq{aNWi(c@y_BZ|ErZ##3Jc;ncsDkaEc}802^izmM8cPKv|7P*#dNDcM2+VuFkj5bP zsBY`CwDL0+rahcs3b8nMR*KjhWzm1G>Jam(K8g)67-G|<%RVJ|FS=Vm8~Qt%01*@- zK5AgV2FobNI8Bi1m);S|P;4Z|bI;3C^r15zn`9f2qPIgEp^EL4NTT+NAnbgA3zl!^ z{C?N-K_MHKl!MmsYR&niLHaiyBKUl?nnMHoCjU`6VHLDU#a<>6eH;vITnOrocj0>}O>rF*)+;_*-e`fF__3tnbSLhj|F0CmL#vw129;9sGH!eVX>=?*ZA zdCD*AJ?CL(M9gEv+-<%jDK{{;>C>2 zvU7_%wHz`i8J4x0A7SCjm+OW@V$Ov#?yH-{F40fK`Jc&NcAC`B zPqJ)_Q%{@FtXwxA4~MeNbZ5&_7v#R?8uVds5 znlFMlWkqTZP1q;~9%K$v3?dp+3*Q4Z zO933pFXFYo#vRiWBXc4|q0D!!lpe&#%`AJyq=wM#maB>!{sT=GSWcNxXfF{07i=m! z<<=&t0&|y%G`#7#K2DBtz03zp$~8`O1Nc5DXvJh~Xu=u~vMB#BQ}`vDJo_FVqR zRC3emhc-pcyVjp9R+Hf_^)ldn{&U+BHR3aU53^#%Et0g*8eX z1!Rd&%_5r%_b?0qV<4~Joq}I}`ZW;_8Ga}3kdSn&6C~kRlH@_seuD@+pd!FLKr;Xj z0JVpGAX_YD(dU)=n*9Kk^6i+gO&c8@w#@7jAGnf?FscR?n!pJOX5hV zz2$vz0l*VzN%3;BC#5sVL%iMBm&TC!mVYbSBpJ=so^oQts^#&j7$9`V_M1O*?U62~ z;X|h_hU{Rg4>YY1xT%reJ==uQg)m+V^JmEe{Q2JfV?MO3D5n@e^#4^ z&b`jO(V-xukC8CfYorPS+=n(%QK_5h{{>r&TI=)}8IP>R^-Hni5eNG^`YJnOvG4=e8ka$PLbFx72p9aq+ox{FK`P$zZ4Et zd%$OVKi)Uz{}A?;0aY$h+wf6PQc~##DGBKYflUiYNOvP8-5}jv(juu6lF}d|9nuZD zDQW43ckbQ1#|nr1bzk4ti;Sh|&v#Tn!C`V_=N} zlV&^tY1j|NDy;$b#*7#`ydq!JNZ0x8yG)y7kCj|1Ee)xkrapOlxK?}qeZL>~gCCZv z*HY~qxDDa2f_NC^<{6QBQg~$gxA08!M>O`X#@LY>l_HzrNv8ZSjua~?Vw^?(OT`{sM7z5y;uBMT@^?Af!K^)8>GPX5#?W)gIYFs)Gy3)@P;}iS3 zs_!x93hI7TTe^BVX1|e!QQ@c}N9-Modz-|lQ94$=OQb3^(%1Ay#XsIYiC^H=U6PE) z!LfrE_zG*#*wct0^Qr#BWuSeG>m*K8qfbI>-1$ig&HML`kg}g;P|Pw~4`0hh-2Ihf z<&2Q(*NL%=uO#J4a!*n3NWC_VQ3GugVU@2RdPeNy*G*BtLN(-{{2q!l}O z9Y+nNRmTIK&;AJ9r_LpF8`0=)|Eh!9K|z5EsIXC9Wc9h@#{gMKQ-Lnx34>xuGinQD zG-hneiIq%(^X3Kiezgyl#yc5kr1iV86ebRR`9Jg+#lD*+Xw+9`9Sg-}$Rv3hntP$1 zdC%6~wj#>Wyl5X>De7<~U*RrVOJ@fR47X|JTk+c<28WHRn@sV11(mH}I;%zSu z`enzyZwOMF$eZbEl_Tv}COhFJQ~Yv-uGp}Q7QgR!D7a5lDpiOt8T}fIFZP8 zd~ur4q`hqI7LC#~aUu^#RC&)v$&r4+m@8c3&2N92&48-%&rNB9Mk>A-6qjW~1*r@|#Pr(mUCyx~NHX9dY zl`s&(6|G2%m%KEY{yA8VH0wQPTQmZ% z;zPBzThAZy-dYrOTJOx}Vy+yXD|%Ion@kZtav)UVR^s|FOHhqXtF*#R%<{OAb*q=@ z0JAcZQOXS2XI*IO!OFEa$&8e8c(be2ngF`Mn8%snpDV&-d?n$$-e*4+#$=qF7M1R^g^9^m$t#KlIG@!xn7R?xXFmVbHm@~#y{El<{*n+ zDo#K2Xt>}CT>zqCV}E5$ zP@$w*Mt?1TTFM0bU}8_VNZ7RgXYy%@R2Cyi4ptYhN&8#Z<%YcF>dykS+QvT*322n| zS!kNt|9DW*Avb5L7DRkjr3@$#?da8ERKfkeCjsvK4xst0L~zQ8dPtKiyuGLGNa2u3 zD8-;7{#ovpM*X<^n9)?;^E;IfITQWROOITfC-Nm4uwWdTh~Nz&)gZl&_<#!k(edU( z)q_9NBfVH2v-(L?THn$bQr^f|Jo4q0epoK(kxg`OGnvAug)k4BA)!NXIGq}IxfuHo zXE}01<4PZX&cl}XOQ=~VG0cC|P?R4E;|OF*zNOuK2AWx_@cWLs*_=Dgi`>V!RuE?s)3cTfSeqVPr$BKf&DY`6u|i~zk6#k4 z7`Q}u8-fF2C*S*-n0JQAHze3EvI$dm+^7AP3ws|O3~;Qd5OwK)@G=u+Z}R$`V^u8J zBX<$$_nA2Uw`A#u#}#(QgZo{?-`~(j!F^(IiTSXJi(KxOntb7EQ_kRzU@RpoV5A|< zvL^Fzy{r)zGrJpZ@q?kKOZ`2;=G%Tw@Ir#I>h5nJ=jdH&tF2Zd>!`bnJV>w=EQk$zEqju)dX7qZGvQnl+(XP#_YMva_mTZ95r zO8>=IY}xb`lV~`DbZ~H)z9b@uY^G_q^%I8|Pwcy6r!gG)Xk!D?U}{mdk_0}5ea;8PraIy)>R9l?Y`G;p5Rn9OS&?n1uZJP)oq#( z7pI||Em%lq9F|v%&cMLDF-Gk)8P{R#-W$>+q*-g6Wal8$3!|D~a259GMoH6mTPQqd z9Xf8Q6QJCE=o!C6oLv&`nGq(^&~y>e?ZMUugV^9Ol=$j3d12ll7N^Cf>xGb@ z(kN^`S5C%bu9ajrDta=0OlfVTQGyKy*HOc0r34J=Vsa&1_NhoHXHCt#?4d67De>jeWyK?Au&^%)={}hCfe>}5(h60eNM^fiY;*4HY_E8cr5E1Q?#CL z=9cJF8K6r;7h~0Z4eQx+FfH>y*ouO;l+r@f?j6DG-IqVj{b)K3TwYDn9(~x^E*qi2 zI8s(;jT9J=V|tuo_dA=G4_$_%qxb%cf~%>m-sO6qToT}%ib6FhTK>U#Ckt~e*p>S2 zcZb_%G2@L{#6Q$^uHtvmY!W1M^ri1qY!_e{4{e36sKPMcutYo~4T$Ei#htl21W__0 z{ywguihq^S`N689#XmtQv@#2*(N;|l--Y{e!32a0#Lp=MFdpI<&28IujTQ8; zc`KulIAaCV!3TDe--7vSm^#skb+cV=jid)(#3Y6pCBdrkIcCCfBj_i84~C5p0myHs7I%o^#pmDpky{J4mc>ZvahjluWWbp3TOGHpyg%+$OU-rKet-BRoFF z>5ml>XV}WyLwzaA<40^&_WX|pK9)&3<;q)!MDSWS@ye&Tx6PXMU;m~E?}*qEP1ya2 zJ!`oLbM&8!mLN!^shUj7!&_gwd=1j52i+Dc?9q~o(QciVD{}Z}DvYVG6RLP9LpYO6 zbsZQ)C*0Z-_t^tElw1NkT${NYK9H;;;{=!;Mj3~EP9TsA4>@(1uEhA|ef>)yR6(Ed zUWZ3_K$_>4U7fuX$SYJSg);p!<7CNJ&7N1&-PMPVh$PI%_JBH$!+5z+iCxTi@>45U z7wIsFbTO+e#kOWvHQi=EuO*1T+DQPXtiqG=E{{&@7kG)yN9Tku@?!raZOI1J5(;n5 zN@#GP+i`#6QgZkpsUO`jo&5DUkYA2x>OP&t#G8Ws_n%}sDO60hFPz1 zNM-_%B&1M&P`eOH6et=u%aE6U?~%bPjN)+1|D9Pv(hO^~4gLY1Em9&H<0OWCFOZU< zeuB#!`>ac#CEtMtU$wk6C5ClQ3Kdt*SHCC3zD^)8hH8M7>D1MWXglG~vOUX@3?0XN zE*I`@Qh4E8-S>{N(oJt^CyUCO{PjKe6%6Ku@d`vjx4BRMk$EG~`P-bYb6R)v%@LL6 z+CL1>a9$UX%)Gv}LULMs8BL7GroSN6B!2BER$?d}>iv&OU^nT~pRNNT5XRQLHzG!D z5dgj$wVkTGM=E{HoPaO3;5T^c z8O*#avw~6{Y_I!az@7B)LIH6Z#8gZTzJeZ(WLhdm*?L&+v~+#SBTZa<{{Z(x;|R%p z!FAkx+}{07W=x}Q4%CTX6K-bI_sgR`OKnz+o8zK(C&`GH;4}y(O0-4(kln9dW<*k5 zLFtv~BPyI_PwDq`jWlR#F|Mb17A=>XozHrqSxhsv?j+qn|D33!RweRHVx=E`ugSD~ ztvESq!tzmN`L|l5V3zhf8W%-&JxLsiibjX%3CI_%EqM|Piy?o$cp5jys7WeOHd@XU z$$uY{eoKVm9BAs08@5+HVvUY%imZEYchh>9wT;?((=pbOySOoOEB({#s)H-Bw~B^y zkH}qrtb3-!SbC<$+XIfyyzi@ydGvGzvwweTpy9##s^NfR-CfVtG(u0{c3!=k6_d9R zp60FIHh9nZdy8@GR+H&_#pBwt;Y7vv*`qjRBOjyu0daUdYLeeWnIJVVBKXlAHkDe z&3dm%HH53>NtgWBF8fVS{kK1VBe*&Zu1%B3b7wlfw-jOf^Su;Eu5Nz}-FMt<>+2A{ z>zMd{jrx7_=J7>w$P9eOA*BZ?NUsydT%g{Pyj&f3`1}>@W;$?EKEj}qHGZ)?5GMjl zZcy@vMWX2NZuLm0O_Yf|lhov<#I3G!SLLolUA~op;0v4NqMK9j?&^VNr5Ae=yK1Bf z7xE%wmv9BPGSV>KnqhIG@qxsjgRbRf<3ESo9=*#NxtTHxn>OBbS3{RZCWqm$k8kzn z#)kP@_5~vbthlh#^wp2V`DY#s9De>rbvt(->o^qS+w7V*U-Fjy?Yo_Rq%BIT%@H;V zf*j&&%`foA9$gRr*-kV!`bV}Cq}?`AeqWzA29DnYg(C`Q8GJ*o`V~9I2jm0G9v+Rc zf)o1Q?irz2=V6+1bHOY5FO)7Sh!j*vtdVsE9V2g6y=gw*`Ba*kt;^olO!j8bA;0<rkMutzk(fpTbJOufh86PF6OUdOWgjmd-NG^Cz8Ao&3_l5sriZ@ZIqYxS((KF~B9B ziXqcQZtqa$i;JircN@e#aP>9&C*jXFI&!}~W6of}kv17=!-gs`s%8$>mfrkZ*_ekC zLVxNL^za3&2=fe&sSP^1?dc`j17UWLk+Upui$hXk-M^uq%IChA?UZAjRg&BMMJ$Q8 z`pyQM_RVQf_qRZ^71^d=?l)8&NdDcVq{ANJnR7gCSB30^!`OKDrwo=$MC*yYNU3^| zcF`@TuBF3a7-)D>f_H3OP0*l;pC6w;~KoiB31Z+z_Y8 z4DjA1tKYsiLP@Gj#ZeVM?TUUGESYZijlis18^wGl?YBDN%!Uo^+@o_&zEa5}lP@lh zx~KJm2AL;bxQ(j1NVphtE$pS5b)qqy4Oohf#nY*fVjQrL)jTL9cPF|Y?P57O8hTsz zV-YUGcTd8^xP<#|Le5EG%ZZWCew3lDQ%zy<%mgM=mDbB-siCUg?V`kr$TKQy8iUb3 z;b&MX;?3~_7}`6{-}0%;6f<`Wg2OvwtugUdj8>^!qI{^Ipc*xmd>h>Aax?8ElwQH% zGLybO09R)6cV^9_tbL~(x@!POAphi<7pkYOxfgwsS3KEP7)!}0CuiS^As1NOTT4wT3h26-w-Ft4Dj8RP1qSI<1x{tdTfgy z>s}yRgxOsk(>%?T)o&}?v@ns*uC@{Odt_7m1S>Q0x))7^C1ecCpEt&+TVO~jIBQ(H zD6Sotx>J#&+jlekU%0`27xzkTR1AM;VnH77ZYPmQ1`JjxE`;3B)`AMXFg(Y@AmoQ=;;Kbz z&LmrS3@GYe`{n&!p)St%H`Nt?N#jO7xkb%f%&yn0F$xW}#mIYJM~RMRIK5l5zVpiM zEIFES@NqYIH2LQ&DTQ>ntu?zUty94Qa-b!jY=W6ERcTMQTR2&y$xJUQ+9RHWOy`7h z6$&!lhRAkm1*if}V6#w$=a~A{Z0QaGjg2K<@kw;=Fi=(K%$2eqQq>DfISP&?)X${+ zpQW<-on{>5rzLDL+#(*|t4cLHoNi#>E|9A;EQrW1@l6f?tgDGxr!mawi=0a5i09aG z6Cxy)26K-mO_B=^t8}n=863btA>|?=m`yhE0gvIlLROB2r1><*yMJzJ|HPu3sIw=7 zdCP!Ok-Zo#Qd@tRn|_|aUk-b&(xrS*b(o8_mu-jHiIayjK&=70<&LpDMv7-Gd?ou&(FtEt?QI3D$O0OFfOE5>R1im3cHyL>-(xK z;<3ymTY|zz?>LJpcawlRAPBO93u^to&R(6dLlmZYPEIqD*9Ycu$ON2svxeU*s~(vB zIb%@^Tj*FcZtBDfZrDWW=5LDKiEOS+h%auSvU8s3ZfG3>8us=JW6U_OO$N-Xw7)V( zwj4a_H|!^L9-au>x5Z#GQEey!m4xtcHCs#T1+0!vBq}e{ZsB0e5BOeE_w^KJ^+1E9 z^Wi<)oE(ZBSk^qF>|OZ7VTJIeWqt0gY125ni#UF2+V=vEl5&rd2(JsWOY*LgUnA zC=L}MwScz(lF>Cn=^ybLIkU&%N!eSr#Nas~m{RH1{zHdMiRUT&+C5>yos+9QA-<<} zRzDm4k}#f-m?q@5B@kSmPIbz87hp@{I*!(P2_5h{R?%B)eas=7lbxu}`BXla@r23g zqRHp8w~>B4O+^O3zTf$(Vjp=&Z*^e1mKc2`-y?W1vhP`&fBn_ZFfpsqy9tcS8ZtDv zO6T9Ye8CNe;B+_x>b)w~o)A%sFqABZF zYs!8i&-D@>yiY5j`V9x&`ZcdyJQibznf?Pj%d%Mg%Xl1{k1$OfMwmSLtjVYhaTvcq zYH^-^UDfofKW)dlG|-iyeB`Z!75IgS!~k#Y53GW6dZqBxT6v0T_x3v|+#kFwvt2a~ zxp%0PFz*yhWH4kpFz%>}EXlPbm4E3UYDzAvTl{z~)K={t$kVj>`D*5Z`Ge>YC zOAY6xZvaonD-vJlh{GGLs*atrci{`u-fzvWjtC+^g|ofTN)!S=@FfYx(#rQpL1 zeC|Pe)Qcph%*RxoG}^pszn{?Gfzy)@bz!?!KA~`WIGi2%fCcCI*-WUKi?kQ%0nN^f zkGVdJwKo2o?VH9A(30^iFI~;HrZW931-p zC7kI8GqdzFQrm1;c0)TXW0FR4cnk^mG9S)^hXTyQ%!RmNoxu{XP8QskLiwrh6a3J& zSrL%y$cUS}bu?ePBPZb#PqNZ4nCl_C^O^ct7K_s(a=ek(#L+o zSJKtr3wn~4U@ve|rjhUTHl1WnrEWRYw{rXz;cBNRlttxywA$*u_e4|Q_oXwY$%y8W zilHsLeauV!D{0TUHyy`?->C7!NmV0W_}yMEIZUcre`+h_qIq9lrXLk$$Qnp4~q(c#mAa{xNL3 ztQagJn>~9gBUUv3`64uQIs+e^Wx|Y3AfOp%4c&@32!~PO62<%@V8Umiz&owQ>1X!R zJe}gMLLxs$Raw3`I%qN*f*aR9ovzf`d%^HQ2NdGnu4x6|$#aYjEPXm%7)Om!_hpwE z(`C+3!LV8Y`P*VG3uwk#GTg99!JkM|J-*qMi=MC(*a%9uH0g44iLP&~V~*cuh1cxVX?EIHZIs|u z5yicnr%$myp$zj`5#n;029yYXv3IEH7HFNqa{ba>tZ(V31k7RsVrnUypu0V*xDn52 zo|4=NmODwr4D%28y#zl`!5m?LE4eWuH7I6CU8O$IT*l@^;$vzG}0WS*bFN> z45w2f|13LJ6)Npw#e33dIe?Ks^V~_H>{?*$ttqb@;g*%OUkZo=5$*rxWdGMZ{E)YY zuBQC2rv?L3-%p!Y*bu|~6+K~i#R6GKlSg_^y|)zGa5riEk*Z2qDwvZjaw-iJXJthG zbGt{hfAX+>OFXw@X>)I{*7+11n3z~r8Z-9>y}bhhhI{C2bA=A-9BrQ38*7%4DmJJ{OK>KGcf)-$+8 zT1%M7wz2c;@6C|cr@M;*9YkL*C;Trto%Lz9sv4a4Y4V~_c>D}lb+}n`$XIti$xWB3 zffQfb3`=zGcu>-A$9D9zeNw zY10xb)QJL&bKWCE@WH#G$yv-^Uk1TVbu5)>R~I#dUf--0e#f7pT8;s41%~=O4&7Z6 zga7a8RILaMdn!2_0n2TcCQs*8#UZTrN> z2$2**to{w||6Yxr*G8fF_sBZb{#Bj;2o?K3``^mrf7R`;Cwj9V$A=<6BXm5jHg>c9*Eq62Ki}Iy@4$eHfOVc91fWnbu4g$bHSD!s_U(65<{FRowrADS14AQt0|`xB{y!DS$E}A) zKFK&%bYF1QoB;yv%e|h-O##^1jg0N2O$M|ov_#tQ8+7H2uhpI^4!UarXq@!xLb-dKBn#W47M{`Gt?8Dgg%*B|^utQ{|4 z<{4y`k^k9&ks7jn%kiWWa4IP;KWRL&*-8P`0*b4y72f(YZgX~gBK%^C#K0}6g|aCB z&kC3ztHgc&-A=Cv;8!bHN!DbAuv{x z*N=}-6R%71#-ejnMsdB5u*0NdxC-09DoeXi|>);&Hiq3KAASm2t*n zu=+u~cx}ahcj62ZY&Y|N{uA`gbnvV8`7KGwY)9*nzogADy z%E;m+!m|J@k4Oi>y9J1SY*&^3nd8071lclhE25*q4BVrKIZ&e}u3_Qo?JrAy3dA`P zM$9nrJ6n&2%mT|8654A%_=zIs z@j-%X#1GstW^C5>{JV;0gg}^$FJ_KFe9eR>BvuOJIZQzzx z!)ap~yUE@4W^5{2@IInJ-!0K@QTy+Dz5#d4O_q5Ew3RAh5B%fn+w0Rb$XIP?z6OO$Q&_ z@fW$QSKJ4CFry8cpEJ+>_X+(~K)Drr8s+U-H!0tnY0y=*uixOLDwJD$I2dN0&H@%h zALTHa^H*tq1b~gkdZAr>qP2}?_PqM2$b zvZXyZxqR$!e!2qbt$=Oy+P`{B4fdMk?(<_;ykhida5AVxgF5dzrT?8Py>&mvA-U~( zu8nm{&v~$Gkdor)9ZCx5B>ScLw-h_G45hEjjOTiG8Y(kJai&}r!H|}j0%CnyoCtTd z`9B(9BHo@+=zp{B&jwC8wW%rFE&&5|yfx*py;yOxS&{k;TdaBJ9}^(e;hN@{o`D@o za2c6kuUY3`b^Heko@0=CL0Q!2$F$(QjL~<;#_&m+p`YjZNSvR#J?M-ZWApZCunrDg zC(5jS4&Esk9;96JY)|4JJp5;6^{*q1M9l2?e`b~%{03!w(R;6D^@-uR{5j~ErhBtb zd0Xj0p{6Pksu&936iu)ntGClthVMn-)c2y`6-(o^2b;LxpLnp0i zueHge!q9!u)3RFS&FNVt$NJl{FN=+~O*?hw;K#;bva{3D-+QKf*4}o1ckg6<2dKX( z`J8R_ph>9b+P{)4uV8XO1ci`I?{`i&oC{(r;(Vuk32l;aWprC0>#QPFIyI zv@(IpI^UMW&pKb(oB9|bK`Vp=Yaj{ECdNJso-EhbZ9OVv=NVo*FZRDEuFNnL8y{Z^ z?~$Z9h^H4^(J$y}BFtYPA*s0BZK;5sZ+L)Pzt{S8DQJ)A6jLnJuh|BcSx_q$H}ON* z3Ln{IzIhMy0teUZv0jDaRhbQ{UbXhhog04z0nqv@whAv08t@d~p`!8|4n;`4Bt-9? z$LpLr+@3hB_C2X!Ni*=WZUE7mWOQ`G9ygf^Hu|$*SmYofj-@WIZufgrSk90I$hFTqH9w`R?A0vvH-iwTXTtE%l*A!m!zO+0Y@a6=Vam( zc+j(i{(5M(kWN0zlz`4rhiv(6%U?_rB>`>UYbdd|%A=r8=_mW*ozasxyu%pF&D+^S zFlK?j=R5PRiM?q zjmY7}7P2Kjhs_tt$4&#b59wqZji$T{?nRzN{P}M4Wx?%<_jYABJ$qZN8i8fLuXc9_ zh_quJL}9j42>+mf4!MVKh#VxuEqL%Bw#MJ@>B;vlmu~&=Yd_3ck^eBIIDC5mjJrv0 zuBt91k5%6IddTES4~@x62+Z^QlHZ}g;+%i?{yxPzPcBMA70Tpo)zD1)gPBT=k6R1w z2H`K`8Ik)vEVOliq3P%>XSXOK(gSfsdO!q4+L{-!MgzItyA7*=BK&Fb!9%qMMsIKh zCrpFxkZ!l#-e45Y%)|np`xZ)0icqyE{`KNdogwI9`WAv7=b{oMv$azOxxzZOADz=XfSVke2`jOogDE-+~?Cwx{1Cpu8TW_8Aql z!trk4$-cogY_}n`QcusBegh4I{YXvj;Wh1Oo76Roy=Hrw&o977YFs*PA+2uLZ{|Y^TJSkBwBd^EVq1f%=0eGp7wzcmRh(s$Gl522K zhQ2&Pu6Ym;^bdx_!aamLNs;Z;$21sZq%3;Aa*(wyY?(F2E7Wx#2Sg=B0RW==Uux=4 z6s*k{??CVpy1$xXd1C9P>Icm!3et|KSNzpnh-hw0j0gY-a1!I(3F2hy!7pt8`k9N_ zayNiXyZDRk+y++D3=Ncoc4`o?hgwnf(BS4Ejc#a$DnAo^+G7ME;HDg3mG;j<}H)`I+v$BYe(q=1du*}f5kkV)}X&J$@(~b zmCN@>n#p2U-^5q|JhpL;4SUym+5$>r0*O8)3Nu2R`~ceI!50=Xe>d3)vB?_HCX=j( zv-|wQSS8={kkM{(v-8|GrPLh~J{)lR8Gv$<^GY#Eb_^(84itlUy2Vzgv6&ovmPz~f zt{3-sntmT^B%(l@{vK@l*n5(p?dN}|ejl28H`Cv#Q=1$$PPLRr5Q%`W#aNu{=LLCQ zFXfht%}-H-bgjo_4m{nWXJ4_|{EuY(#|zfag%rRrEFwUrtSubF6o{DibPus@fl%Cz zaYiOH1bK5RsY7$+cI)Nt;C*ZerHCA;n{7OB@Q|!Lu7`iE+w(hZ>IbiO!K-X%suI@M z;vgw|K+(vfo7X!Pp{{{{&wnAX3)B^W>KrEkx>BGNQ$TNXf%57rrAy9cx^f+v*lzuD zAM2O-QL^P3Ww)`z*K?p`hvqYssn^tWi+SfF#{2i_Bg8DdgJp3g3Yu339hfTo20!%! zoY~a7QCZa6g}rn>S*;KANcTtBqiRuIMQ^XC*gJwcQ-Ap8DUO_5*k< zyjyrs4;fh}WMrK|nfeNUjSM^u|N!4IO9v?@Jaa7`Eqb%xZGS;a0$2Wna-e{u_;};81M+@C? zHtrY*B3s|}kK|^`>rp^E_HV+`2EmQ!L;a)eCW|aJ75UXC$b| zcfqeegiw(kU&T7(6t5Av)^suC`edwSI=r}8Nm6#gCny0_lUO>p$o0|K`7B-1^F+6K z$8H~`G4Q{OBjx!9R5+;7l3)OmFan{Xzatt#;~%D=!pF(-hsNU*o9X=Gnf4-|l*HI*Ea7O;`e3qg4U^yb@QxsZXh{0A*#h1XbmmeqE zw?uB=w4UQ#R8}|pk!wL};TaVU5ehCUoEwYH>$w09Gy+G=Gw{(TU`zq&><`q`!QUKG zB(myP%5TKMlX=Q|TN(}7DxY!hH0T%e0XYUN5Ej~ZEhZxvK_~LiIHF*C(7L4Je zgkIT8p9uz?)OD!MIt*qMSeH8oxyoobF0GF21fZj`8I}9GDgEc2_WIe|o&(TnpwM)& zq^8L8au#%L9GkVN$e!zh6#K_5H2>R47P`ORx`g;vy!(H@RTgz0fKD?wS3gU2K{=UA zKS?2F2v6UhNsLpweM1=kxdGNfw$=NHL=IYSLrYE8O_aO7C_pECEmssT7l<-Yo>)6%R`lXX=tDB zwnTw_Tl_90jR8r93a5k`5)JQfGo}!fA5Xw;t%~{*b}wJ$C&Im5 zE@JEcekBi>#c}N;G*86miBJ%mmf#I;$~GXlYT)4$WBK9XbW(1b81G$97u<`vhWKe) zPG)TyG6RrM){>XN9Z(0&rWm)s18PG9&J?13{X3vHw0AysU_d$b*C2=%eJXEc&UPLW zu>N|F(3VY>l*a&^FxDJT>N04rX=t0La0C2esW2wERhk4mPX@2f_f_V*EN>>w0@3KL z1bn`DiM67Dp_e3pwk_$z#nU5X<#-n|xU>d#d>4NVA;wt#r$CGA$I6|atzpNGM4Ds&l*5Qt)bNt&Z*}Sjo|D`%C_ zq7Iff?PgH;{Q2tg?E#+;YgdHCYcN|2DT)nM#y!XpU+g&eXIB5AeyMi^r5bcyXuTus zG;fTuLH}Lr_Vzm&b<8~vs!qDaZa8)T_aVTx2{Mpjlup6m$@=OcK^yvKe2X5Y7YloS zX>FuSM2CZXhIOxdk+o`PsVE^nOc1y!U6YXbCj?o56Jy<}UyedhG* zr16l!_?i$)g#XoeR)ogymE$m=H)6U215)rh=w-lv?2{lx(7STq_yVk4rTU^dv5b6T z1xIhstXt@CfU8=y?dqW4(&^m|Ajs0d!Bwmah;?f=z#nVTD{AUFN=aby&cFV~4gR(m zLOa&rk%Ym?vWuBVaF^eCVMT?hI?PQa5nBlp*b&^m5d7@0;b1P2?4sworD~2rbj@(f*Sx=?d~}F z6-H^nDJzP`2XUlpmvLe@9z!hry>v+hV5WTi1>~i4qw&H)wOc^#dg(B6^_>TKoTKqa z`MzOt(P^X6T9o<`e&2g$Gyr_C^+1KVEW*vS^M^rJsId45-Z}f#%XxsKyC6l=Kmc&* z2CT6ABd_Bo!e|V`llEjM0HK{mple%cn_w)Wnsw^Z$5dPZfsZ7PRI+;`7uS3Kxu7-T zFWVVr`#0wnLH_8D;K4tU>J*DPyDy3WZ~HxQdh_Rcjjyej&f)iuJnOm{i+&NuI8%W! z0y$G2NqL-hh$L#jPNg!F=AdK??($fU;j8k=m&~faBOkbb+IB0V8}+kK&@3yA!izDf zNZr|3c<<`4N)Onv0ljmkL#yYk-{nsA^HoZI#zK)T5>D&vBhtrUnHRhu3|*XD1iK)t zDmn#%P_3o<)JpJBEwRuCq^Zl6=;@64m07P-@UcP73m)C>Muttf94ZFk7uEV+fy0eA zNLW1t3E+@IU}=-t5q@)0oqNrKk5egJNVE>fX5Cr4FAJl)O?x?LsuBCf`^F!PM~oxH z6FUh%#KEq?wa~s@kMA#)a%pA!6491=8FIOGI(w&;#jahHnv@I(%2SETNwhD1e#7R> z0c?nFYxjW^8^8RqU(eUD6{LVR4)Ns`Lh-CVtnlGy@BFDiNc1mNlO;w#C@q8=?FahT ziB20D7PSEE<+fIwju*!i=~g03OC2&bTM^^^gz&tFo#MIvKz&A&)s)ihThG^gK7Hzp znCKzGlUI+xvY7_iQ6}^(RL$4>q-KHYW`C@OQBLJ&sobJIWpXc;^7Qn6{8Xk>ma`mD ze_gp!vZN9ZCaH&;GlzU<4p$SW6-5fT2>^O{Q>jF4j=%w?k08LtZfO~TpGez! z-x1HDQ&80~o^BF{Lo$S4?(^qK9h+6m*Cr_+`+RF#I`2bSXX`j9py_$-jen`edSNO@ z!^J0_zP=fCMp>y5PN+RDv!d01ADd$SqaaJX%(6=;|D!1(N3BxRaNN>(Mc+uVSL#M< zPmLzIrSKl?#^A{bpWf#5@9XOAYYh(EvBrdtqxRMlpdQ&lXkh0UZR0N*c!EF!+CmCh zM~rvwJkIT3Jf2#5(O)#>m36x44TAS%yhIkAKs=VNEnd$*#OU!UcjJJ;qf-QBXhY2w(y^4Zj?zWwy5|zj*+uA`I`n9!l zy0yONiwm7{qru=XgtPcp>OEz=aJ*&pu2R2`!;h5pIdnHQ$C8`&qUkENUxKN9(psT& zEFl1=1n3`rX#d1vn4$nz)3f*`HywL<=!$+n7mvr8Y}##nlBD6QkQ5tE09Q9@6C86G ze*hr_83-YmQqbOqg!^;|fffmVqdO+P+$+hCm6r`jUNLb(zaxH^ac>AU(KZi?}oa$RbQDF6tj*terju9YBl$?2Ob&{tJ+<$S8eIZjSWiB*Ya&W z3{=NzV1-Ftyk2K|1UHKYafJt;wemRt%fPw9~BRg@T@;Ph#*I=Vylc5+d#?FI|pdq4i(C>gntb*0`jo^ zv!KrGr!4ExN1BxSQfHG_TBh}Tm7DWkTV?5Q_&YV$_(GZ}KH-}@#_B#-o*(Kwqk4$d zlx?1>Jqn;l&ekci+K_z1$dYMSr`MsxiohQYL6_jD zOxi^5t0Zy8IhEO`-l!k36Ts+q3z(&d&>sLoTTKriR$DUQ{rEo#W7;L z7Fh3BMY{tzHwuzIrpkquy3S7%7I4B1&)uso%0owzte>4Y; zK2%dm;KG-uQ1LI>Bh2+rSoAoIiH_QbNRQ~^>dYP#%EMsl?O&3-2ek5dHfQ6f{`xp= z1Dr|s^3=k{mwXd|KIdA3hb%A9KwW zM-17iGL)ee$H({~A%ZJ1sQU<4%luI@4;;^3skByP$4xxj5k?S(XQ5C$_+FjpY>?`{a;ZBv|B;nAm9Vyf$6xx15k)Lp*f7QA}GWJ zF$&0c^P!Y+MC$1*ltqDmaUp>+Cu$6csyKNi`vXA7E9&B#RK=(N|9@WsOd=+RkM7qVzxrMfjgJS{jsDXuS8%2zGhYVumG7-x6w zqP!)o-IpmuV)spJd@SN_t#-{)b;fe+%gem>JLhKnRF~<{{*(R(g-O*@QXWq=#l?&F zv3{Ri=Qh8z+Fe1(`3*$U+_d;n`yy6&jfcnu%Y-75ko0a(zO)rWDF`b_`!)cLtpuHr zin3dSaIRqThCb7_>NzUJp}6dcoECNGdR*+>SE_=SGYpvsBUnC7l6R<5= z1h9LFWt2sZ)k$6mvCG}w^rfrblS3bb^2fDw%N!pAyaotZ4NE)SPj<{|Sb4pq+Os~- z7`8~#eXfF4mhJkp)VjAY^*PtTQ^_X{@q^&Bi)qT;KA9_>>=lM&*dFhbuEVKP?vD4` zne`A?BmxmSIoL`&SRkEfJn$X(S%QPZm0`j|fz zEM2YfStrWT%VCS28Hqlfyi>7T*-1r!RVH%uLz0V{`r{TL01Ri%)AUEfDE;1o1MHjP z?~&h0syB)u zC1_WPO&VQ@AUZynYNa)eVyTiPdvnQS2ABAMS??Yi{ODxPp%_YdbyYQV?P|FJ)nleG z#y$2tsq94$D*Q9Z;|fCv4fvBDcCJTr4at`BLKS{>!W$^s&)8~=9mQF2@&VN(tXq2@ zi)>mV3yc5fo9Djdj8`OJ_MHdC3fAC*N=vslr(EJ(C}@mBVz*b%OL(V^f@*+> z%~hpyE&`26Ad0o5ZQ>wPO1Vuk49iXmU6;8t4LA&5!t~mz!1EP z9?RPYT^%)mQ?*E|p}9AzRw@+!0}!9H693fqlLHIc0NKAcB1!fGDM6uSt}kyHP!+Uu zlS*S>YxVf2FiTLj(C?}Q4z_B324d8y7Y0+w66(9C)V-j7I{VqD605SA3RiFKp}qZH z^P!R;Je+p)c*-yrssyeX65Lwx4&kSU$>KSwqbp3@Bh-tZBL_);Ivq7<_)l;smNEqD z1-Ok;?>sQgI1*P%Wj6ix<{nlr^)M9OYQPJBbo}X*CKDx8h81l$Ui4-$yG=#PsTFs4 z(qI~u2rlvqV{*>g6}QmzTm+-vt{sj0fq0#s4UTjqFL zxOHFl8{Q=9!|N~YD!&tynN2Y8iNoSk*1-{Z-zhGGYm75URBE*S=NgrQEY#G;|VTB{yg_A#~}{cza-hO4QS znzmG})DRw0-$hL$cHXP}QSrb%l``V7hMwbhm+8E)!Vj%HFWn|2!?l8dRpb&7wvKA~ z;M&ab&e^^rta* zXx^80!v&`ddP3A_h>49Mo;Mx;ONCe?sF0gQ zdL_L%Ej=9UUW!c&ysxgYDKg?-bb|HZm~IT>T-u^i;DTSeHf_u}$G~f@_;@N*k5PkK zaYAG9idR#0+3>~F|7c}(T2MQb${age!LkN|`WGMY&BrIn^(=^E{UT zRggYUt$tjfMj5%URi3JCk*-3xdBcO1_H19=#yT- z+<#3Fz)O&z546Z-(W$@t1NuTVZ=8l*iw<73OlqW@6iB^`Ct&LRj`4s}R9IJE%MINT z{*fW%40yK1k5BA!0n=xDFVWx%q@n}bo8zb1x@1pY)KclfBO!QfD{dlgN)D2EX5Z@j zj_XZeWlncd%%bOL8XiX(MVP!a71@%%!cCJQs{CC0GM+pAZkf~s`RB3=M-iY@UW6bN zw?w%|6a0-sFA&hk?nh>41ZR5+wkHl(&9q4r58>x>X=?GF`iD7KoNsd zIz$l|LK>up4r!E*85lrXK!u?j>F&k>q(r(Th8BsT6&y-JUlsz5ma-I_HuL zbhGx_Ydz1mojgxeY)ajIPO)Jpe=l792`*Y>dsn1$2M=I+{FIBW| z$}h!snw}E(4DfRRDwu5zcRw|cx$6(RL(vVe-J%?$PrNet*1>RAo7)vl3dgcnzg9&y zY*L#v0(H;g#&M4>g?n_*N?_vSzef-JAA*4f+RQ2GTSU4J)TLj{#$_TulQK$!O^sW+ zLHAI^KE*5+KplI4RKxjLOx(XIJHd4)r(x5b$%EARm{%WwjrgQ=kDr#qCWdDb;NSAj zlb+!#Ac2*2^QV{=fu3uCIJVN_yo4@5rYof}>{|tqj6>CKDT36L$NQQBrB#4&Z`B}0 zF73)c2A*PPW+#7=Tw77oDtWn85>(=|md(+2j$GsAUgrZD*re=?d;Eb#vFiaooM~cN zUIRkb69odIao{dRTdw*6Ra=}g?pBGV75;z&CxhjK{zv<5YOSyd27fW7NLji%ZgxnJ zSo@oU7cNrUq?YlPP|f=-__<&owS>d1o?uk_@19sur*S5s;0djfMD3#_o;rEh0WsXF z!;(IwPJ68#2em_KZ8WB&`Mr+9LPucxz0ad%tQLeKWdC7=!o_jMF?W3kA6Rh&E2CM8 z$4DC4s8duffL|Lo675Av1LfQIXAy@rYMK1H5lUn$A2rV0XC%}zALf5wMWy`V(i_Vb zBBMWIDSH|r2(W7X8n|O6efU8tjvf22&pkbJ=YX#~9^li;b}TQOldhc#;P(bZ(w>i> z%}LOB7DhiFnPPFMryvT83Lj!pEMR7#e85b~UZ()kVt*tNYqVkd5Tg=st6cY+I8Q2^ zSmCM5C$5YS6Iu%ttSXo~XcJ1GZ+-(ZHqUc1YWNVNa~%`N{!*uOGvXJcG^bWnzE}*L z{8>wm>SYU3?_XDHeH%DR`eSV4)N#+q{Iv^rz4>>vrh-M8s_Y%VQI`(&B%>VT( zzitD6(-A&OQ0ZT#9W3tz{M^`I^sS~-4M7f~P50HjdkTHk`S&9B&~_&%MzR_{%0pYq ztVN&mmxKHlIsR#OIaESM(|RIha6%(PkWW?Sr8Ry&v(`}sdDnj(kuSr)hGf^NH@e3K zs8HAM7Xbnyf~qm0l{*|)s;-399x0Eb98#nU<)}D_i!fv`;w`f8uRB9@i)uhGWRin! zz3;6)5~YuoR{MN*E+QA-rlV31mvH5$EW!h)S)>^dc{Nju@K^jGO)#Lx4=A!N zih(&;(xnoaLwA6R<{>T|qnbqoM{WX`cZDG%yDgH_+!DM*$dA@&*7ts^cmk+38nRjZ>Rd z1wS4U6u8})$7$Ea=3W*d_!<`OEVv6t>5Jc>U-i)UH+gd6-ma~{$~gn*ZbXX9 z0c;$O6mr-sE4|8jPb%p7RcLsS-X4)`2f&jx=#3qc<17r@7bg0=jPpU)stDD)#TeC@ zl9Q8>_~;ODE+Bo7P`)s)CV?gbqZtYG+LT|v*0BFkfP58e@rci=j&Qo!_E*Vq4{EZ)#fK^{mgPj&~qdc{CV zE8Z4m#KF1BLh0H|p^E9?HtV21vu;g_MTZ$40L+ggyB6+M`tPWcm)0mul##5W8FO^M zeS9tAwi`0}H8*beZVaxv!=10zq`2)@2XyaYUv-B3Ggv-+vFOlDx<0R*!mb%?W;DrK zIK6!vDF3Kfx0oTXWlma=&`Xh}PTouc-qbcD=|I2t3l>Fn%Z9^~dqx-0`)B%rtZm zM|-*hG*>e@$K}Suz`m7Uno)Y2-&6c2r-lex`Uf-~(ggWIf*KJ*15Gwls?dhbvo9Ec zjbq@8)!&$&iN}PeO74C(19jC6H`a^o0#~OcJVa$$gS@nQnYQZRj=0Z?D{M&pjH9X8 zDYKD4KQK{I)0sm<9@DR<$(K&%VfG^Uw#k@$VJ-OA)UdMiHWXr)=d<={v%KO1jS-}v zgwbIunyN42Jm;w+PF^aq;67;^o<1>Prp*)|1Bm#9KG;ygEU4F+`GAbwe?W;)S`#cB z63`1tO7FqoS^P1=aeF+<+dz9U>Q?IFN@l?3@m@_zr1{p2eh*+<2`UE2Kq;J{1*%X~ zI|`gPz&~T8t;vg2GBU^A!0y0#fH}zS^)S>--U15b(Oru*(f4bQ*YJLR4kcK>>8BZR zYf_<;bi?d*Bf36)%)e5b-dH;8PnDscvIVanti8o~0RTNW0MW+Cx^-QQ#82-WhY#wT z5+5`DaZNg-{nhEZww1y5jV%K{+a+pbWn_!UzKMX-&I{{kVrr+);js^ALrC}`JKU*v zo$MZQUiR~Zhg<+Wt9^ zjT(;oSO9po>*K@YUjSW}Z40R1%npEw=EnEFG`lXX?8nt40>WahlTcU{V3=_Lq|n3X zUoLPoJmRAFfdn&j`x{O?MT^X53g{kEiO(2Q`Lg%37en#oDPGPYq5S1W(&f8bOM zRI{DwBmK%l(gVj*nX0aH+IiMOb8cpEG%HCq?&>eabtJ2H?Rs&308zGf)ObZX#F^J| zFfJ!I?hHt2MrXcQ1pEb~Bmst{?jT>mxA?xLufneT^)pP>X7c zA}ZMjxK#LK`J;wn0~RviLNR50=c?f_m3h~y{*SH;m-i43TcP;@DAW0uo~Df?PpAj3 zvU;N;!Nq{rLzL$w4g?yzDv`Op^e*5)p^0-f3X5xu*5ksSgO`;WedOL@zb*knHw2BU zO^WEfXfPnYH2LuKPFE~!>2lYrLFfvg{_WWBXTLRzzRCJfGcrI9SY9kL_EhoHPmt^@ zpb8ms3jRqG@>S>sHntOGpWC8FoZuC|ALsqRcu9ykW+PYv0WHN*6&J{H*>f8`-B#uL zq&r8YSynM|xo0cE+a#_m$lc6F1z+!d2H>3L(+Jui>qo^&TmzrXNHJQ5h>Vf`N-g1G zs&^;>dHCWStGt@qcC-nD^+)8_*%-T_4iriFU`}!#{ekI* zM(QL2?Jjdb*;Icq|2M9nTiN3HVUO*#t5i761%NjFdB$%UcWV*27kr-WXV?RMF9<3t za8DzDtr1|E);qV2>kOg@_d^i$;}?gje^jfJRF$69ktMx!rCtf!W==%K$yPPXST9Ix zP8E>95qSE-axBk!Jmg52X`Ft9-KlO*^V=Pt<;{=xzcB72W(n#yKv%%ts5Eb0^mN)^ zxsh>f-`KtAW^?bT3x`Pj$m;<5DE2)*%qid{)=bqF^m*Jn1&?OWAY3uHU7xxX3ihC^ z{m6*hYu#BmIN0s5O%d^_;6jI38;~=KVAJ&1f;m+d&sgx%{INi5KlX>2`U!Ciul1bn z1t)5PS--Zu(ReeOO7H#BSnu zqpWo47-rkqtQ7^$>orA*(q9g3eYB=FR*H4|MrI1GzndE)7bd*Udrqi;3q5a!7mR5avynb*RiJTDj8 zCC|C@bRWgOJOEI{88jQ;*tH1BSZ*2L!iT;UUwNUOmAXYJtJkf3*GHZ-)-bjmHSx~5 zJ)0KdlEzN**c^`;Jg*#!?qNH8T>~Y!q>hDxw1D!@?=cOp;W2fORh;5nyMes>p*GaM z?4Z6|np~Gi7qUi;K?z%5Qrt31PGP=XrW#28)Chbz^DfYoE~CJ)ZhU0HHyQ04GkOL< z$+=jb@ls#4noQEo{gxTMaK3>bHt zqe;!AmK5I}jfdYmu#Xn%O(L_|Fn`;t;bFo9(pY_u0ry=$^3t(on#O+x@IQPFP_qJ5 z?(L}Xw=40?3;>7;mEaM)iKgI+z&UA*esV!g-ym&cZKD-e;zh9uu0ep>3ESMhpp0mF z0`dSbMGvDp9bBcgXu6msoK9~kOn<+8*L+d$8W5xo_2NLJY9v)vN)BD93e1uLxaOrFN#W>lbG4b7~Me}&7%Pa zg&cXOEro-1$B;2~%zBQR1#zarf(t_Uw3-p;E>BfjIBBg)JAK9HU7qg7)?563!esw< zh86SZ(Mqwv8<({K_ENOBW*{hUGi{&aq(V({>82O;5aS-u*2sbO+UlKO_O zn&l)F5#)6^l(p>$L{Y-rWwSQsxzIRWlHDcl=4pNKkf<>~=_|M0akZbhIdkB=7_xIV zv)_`Yz8TsfOD(4?*L`?v`ehf|9}??98wh7(a@`& zxMB`C%j9fIvI~RNY8kz)P17uoIKQUp!5>x%qf{Do9+<6~C_zMPlVKlT4=w;b1gA!R0$)SLpM$__!!z5M8GH4$`^IIjA;9=d2=}UB}ExT(UR1FjnM; z7fhWFVA?jHKo#uy8W#yUNQO!>&2wyRp-N)O+|w0X#i>nE`m@ec!|mi@dX=^ytvu1| z=jAC-*X}(4PZ+C%RX#$yUSE=ZKi*TxF5Y5VBqTaf2qTuLp?wopX?dI`S{nqsp-;=5 zYqs)@5Y2s%0iKm7>cz3=Icw+4_a@J1BUOa|Gynqxy$H>UiO(hkiq#+fbb@E5?wG*mGF=1%|jW zD2cDP6yow??RnQ<3b)c*lB?kt`p;yQJ>@2*Wa`AD?iPN&7gwUJ7hCq>%&2O~WUcK^ zZ}hg69;Z8Ox7YSNU2k0YUBRasqp?=nT-cQXMN5Pu*9lkYBX%ubk^bc01ts4p886O{ zg6uW%Pk5BxQ@q&}s7JAE2-sh;PnTl!F#r)V&#*N)VX;FpJ7Uc6;}0ppo`1^*JIV4*- z0Tz5wY%6QX`t{|Q`=G$zN6!rS+U7DY=K(QT5!LD~)7@J0D1$054DrgX@ajxvEwcfB zlYhE|uUr3$cpu{^{f%_J+t$^(yiC+j+3*K{f9Ptr{h*{C@pbC+C;T-o8`-v4aB}hl z_i>m%uM)0B4mkXyI@=LNGvpmLY4`4R291sot)k)DFDjy9JeF|fy27{^51`gGt;gq* z56or~NNt++e3#+<_%)Y#$_*FY(3^Bp8H{f{t)QHp)tOnWN^0+ku%=0aZ%JU8&c%qQbZMPGoj< z7Jc=Ns(~8(m2Tl%F`fl=#1JISxcUX^QAw`V2z&HC<{r3j@SXndlg79K*TICPW%vkKJ^N@}V-?>e~_sQR?8fs`d7(KKmfmbMD3R0R&I1qNtkc?-*} zYST$Y#Ls8C&3AY;&@;80-me2xO`zW(RE^EF7)uGKSa2C@@J;+NLv5j87JF^-QXz{! zri5+{6ER>J9ZgbQq9b98L6D?1e1a)cn^x$4-IPJBEVQk+4=S9&AfVeT2B=Pmzr-8N z9XbYAWfEAm`i6iv@<*mbqx5M`^DY^C%k2NeFa8h6GWvIPH%LY4)0ZTXYlvdinndB` zYB8@&78k|qf2AGE0yP+Cb)`k6_-iEp#yiU46#3xPg+!-x&%TcM^GCOloE>u_+-DAk zrE9Inz--(#2sc|wUxCd?g>xcv6y5Sln2tuOxi;bxT!n1p)+lQ}hP&_YSmM4)J_D*e zuvG&=99ZeVuJYU><*rCQIQdHeF{frM8;KRl#8NNpa#KGb=99haOFm0^(MPSPF6o&5 zmzNJHN*{U7fCh5X!|+>`Ba&&1+7%Haec5_b)ViuafBc~Guxh$s6UbC_QeNh#m8s5O zd;2o2(zjp99lg93sg?neaN`lwT39Z(4}eGv2K%!__Yu#r_%CECB6_!=0z|AD8^mx$ zEu>gIL)|9G#dhfzohK?~rk!#fb2}8mRXiITQ|}eaFqNX5V){BA{5d1;*vRhyQ|>iT zE9HtvyB_mysyP1^l)5c&9luGsVq>=a3p@sC%VLyiip&T{AL^a2Mr;dl2HsHn3lDw> zY=v_Ke-I7KT5v8r0`Y5X@WKlrAVz=@hdz+@&PLE?>xFtJMW>*+6+fSPV+J;&2lCar z&W>v8{j#&zV_FWsQ%i63bIqY!Xoo12ll(CvUN#>X0yQiRB3(z6zs<7~JW<{C>sO(@ z{D9Z0GH8FfqSe3uJqo;$dRvp1WSI1BpK?5)Ce40=Fw`HT5E*)GEEA3hztz*lDya24 z6)yyRBb#@S=n%~apU}FX5|@F?pl%n!)QAz4e25n>c2}B`6kI{3SRG>xVJ~^_+FHZt zR9<+>9h4`>ppt|E`if_YXohyhW~VCgNQ4pJSz1Wv%^+woVmM2b&#%wFvSw?u{d}x# z1Ea4smZ5ZRoL zaehZL`v}JR(DjQM&FnJmR;ye|11*QOcixW7>YUqHR|wOq9gg$yR&QF^w2B_(n|FD_ z8z>}wPGX}{JsZ^@X`^VW?RJ~rh#9AyWR7Cge7nt8g6S9FU-{^%1`L$JY{iB@WvI3Z zzMj@mm)$3Nb0In?1nhxd`SJhUK{B|zAi)}_ZA%nfZv?NT>1rf_X~W)HtyM|xvvn47 zj#M|~)TZc+pr;d?yLshF$*~z9bgIddJ~uw|tGt`WAHzGG-namTVXwo-R|tM+9^2sitAL;(W#>I?m05Q_jkDFtQ$tWZMlR%M92?w- z+7K>}%E0quD@q#EV_Hm`Rq5TReM+XOEqi417!*Y+(U-x@KNVpp|BcS}$62>KPD<~4 z#MDdeW1U^_KE__b|EFCQ)UuMM#H66R;k(O*h}h{4g*EdGA$9mC4Fvg%H(qLN;VtCO z#qoX&$ljbmA>%Rp28$$1zaC#WNzn|8$685lYb9e~ENGGm|D&8n1slzA%t7?ex0r0Y zX|=G4*12YC`{D!`wVRPrhG?B$8}U1u|AJGOLc2z4Q~j(WSRd=htU`@XR-2+h=RfU*x`D!tuQIxW8RiAo2nfIYlgS;@@Pr zEl_Njp;Tw*@~8647jv6?VWo8U)T;62wgwl->8m*GrU*`XrbMqFxr_>uTuHrHu>9HO zumA-%4oA*6R zoDZoPh+h)Xs# zoin#N&iNF*#G!EM?-mEZQEkeRYXKM26{7R@}~h|9&$P|0N@- zc8NQSBsz;XhN~pm@b#B?u~!mdjfcj&tfHt#(y)Uj3mw@D8%=$fsw~u*e0dXGBA1_L zSpWH+LT%Gp;zu2Bu8(P}dX>)V-EY-eB5P|s)tM5y4Wg<1w*_Bs-%x*?bffnz52J0C zo^~C-5u^3&&0|C_bDEFqZ0RvV2J_q0Z{!T>bsyrjzmb0d`-AG6(~SwRl1XCNuNbDR z2YBq_bEXzKDtmjAI!BC85Vqf8e1;&(x-p4LP)shyFJR*CI7i?771e+dukRdGRe<`1 zif6`SD2e6MJNp%6PtQ^J^AsS=`-EY1Bx{KfpEojYXe$Qmm+K|+qM?icwDCFgZhvj5 zG)aFZ@@rrf?O#x%xnuXZFc^TIrYpU8Y}%95i~x)5wt`=etWA8qTx@#foc&Q75)^_7 zNxZ6RSWh`G4yeAS%*4*FZf<5PdXjXOXdj$hvmQsvj#NMxJE(v65Gd^I0-Qn{Mf}x& zoI(yxo5rPd8ZT`d)fb9C^G(s`{YPB{7hRgI23zdYB1IqlX{K6jI0zguEYCDYWsAVWRl9o2V29IIS52gYg{PMGN$ zJ!s$P7Rwrn)GrKBTh_iL|)N3?L2@I4w>}Sys($96FAyH4MU!Y1g&-_ zT%3t}CU<;;AQuRjJ|v4G;|jE(ll4YE94~)x9J_x0h+3xVq&D5( zDr$kJJe6ir>~0O6=Pn7#k9r>PEm5XCpGCh+zrWu30-YIBay&(xS1UNM$v8O{fKf3! zYTtgVIy=5L|MKqsX@mHtU__bUu>@3;>8vbYGdY)G)2UP|+1SE`y={Foh#;_S)!EG~ z!&u;vq~3_?H5SRyEEwlRmVR}_F18s;=5*b0O5Ch+IlcPyP4x#tW^qNO#x_mz(p?pi zv;T~1FJl0*?E|?-Hdu5|!toaVBUL;Qt_K4Q9X|HJ- z<;jNHj)wf5ifXRcWVJGRF0<4K6?;R|TPDa47yU<+%fH8&IlnW^SGR16?lrDEc}wl= zAe%qKu1WFKGn@ApliodaQbKPUR!M#6qSL%1v1#ot$!)3{txr!9 z=;CBUQJyeJtmm>OY3v-XJ3UjW{N#-dyzF>-`~3XcM-C^?wLei7^qHS3biWsj<6^U< z)(zq3TO@r!UcHt$i`#5&?Iz<~E?`^yf6E>GVA6lk^0P~z*LoH*()SFWSl(YCl7Ey{ zrEp3~#&#(o&QkWS;x>p^&<2)3{`e2yitAb7?Wc;7Dc^fc+)Lckz|l2Q6|@Id&)`ZZ zEkEVB3=AC%!zRmF9aVCVC;2|ppO>V{wBurueRC=CZFyj>pv$_fNc%XRM}aXY5zr>DYC{5^K~FM&P;Lk`bOo}$3Z zF6xu&uB$V|krrMTZ^%k>F*M_T;}h7(!rbF|~?*|0zd;&|38zY<1=kB0hG zmX5fk+*M1_$)~72Tz}ma7nR~gxan-S>{~#4A>gTT42BCG3EKSj+)a)1e(K~fz#bYU z#C_xLNz35lT;4~2+ito&_}g8AlX86bgAAzs$Jklm*6`Hm*DwHF@CVdBgU)-D+r9$2 zYi?LOn|jinIjDF*0bx{T@-xSyl*6|$041BtDY}BnCW-RL!ZwQ|?CMf|bNU8lxuS`_ zXHvgSzcr}ziA`2PF<_1DMq?OWet;}@a`2e~qBB8O+PUqh>C-@-A0{qbt+SX|*B9sN z>`=4&%|NPKmot^~nLjN`N!a zgsQz&wlo2yyHixP2cs$ZLc7Oe`w7er80xa26JJ<$QR(gzYN_XHlW*s;pTQ0wA0P+@ zwm7x2#9Nhh{SRjE)zibdy{YL*__597j=4>{%^n?d+C`Ej*PAj zOF;j7vbh&qV}1K@iEyg*n};>q)3gNKdRv2)|4*3w<{xJLL^Tquj^!C`a7*E=pAgqK z;JU25sZ3FnmEI7?S$pJMJsdD~>+@lte%K>}R_M;i<2R=BRn(W8`RN5Mn{YBJ&6Rd0 ze%O2+!w2=jUD9c>XhM{LXE?qyDerx?dp_miikcg`nMYAP*=J~QvP$nDB7U{<15W2w zYOeDBg(Jg(L&eKs#TPI^85bL9w&&c20*>Fdes-& zWrSfbz5K{@`|7ymXktv(V+m<+G@kHhr_Czh(?FjNlqV8j^QFR_;%^u8)6I3nYkA6D znkPDTM@H7#PeDQPA2;M&&Y2Y`8q(f=s1+q^w&Ph|-t30F1iwUWNqL~=h zV(#j`(X^np_uyKMqB53^Y4ijr66eQEX-X%_{Pon>WEQY1hNJ4n$8$rcob;JOX+1|A z9_5esLE>Gh%{}UMz@8(_lU71<(hJi?aTNz{*#mM#O`KZdflkBQq<9AOeGb9rX^_aE zGp%f~JGQiW6;(|pbAWSi_=&~2(k7d@iy8;djgomVwIi*RD=oeQ1Hs5yn6)lOv$VQl z7t}oAsNX*tiH;13?}d_GO8lJa0GeJ+{5u3q^$6XuQKm8leB|HsM$T2ya}m1Y8^@$R z0XV-s;F8S1)(_y^0&zUq+xLW+1u>q#cYZH_al6aF3mJw~;gq;>@iK)XELvxR1jRRq z(z(qjiEg<9o{7PY{@86)n%lc1sk)buk9;Ac?ZTV6P6b^A()nJJT<*$ za2Z(aRv(Od2dU;O@iWQBZ~n$Nq1!nPUxG#l+q`C6Z5J&;fF=UNMnooe;bN3hwNPvM z#bnd1LXYz;{$C1`-VGp*K@0FY8Z%mF+hzDz zdY#F5kuaR33{bZCLq98h;QY9mU8H#04oI>O+M&G}7J;X)0^SM;;j}gODW5eIGkjN+ zC^~U?T>b8=`$n;bMo@OD`$JcNBWF?P)zBY+YRxB=yZHSK4D{+7^J`VjD#d{HR+?u6 zb`_IOST^HV(=qwbxieWy?(#54*|F8=lEpGbfSH8-2h)U&*&aaT#L3{_;&2PTvGVCe z*3W*nibSaCyPhVtvGA+r!Yf%I+1`v#Eh0cs<)F!VtlW0y>65M4bKd8bUM1DHSkwxT z=~jFIO;+Di7z#FVs?#M*J~eeV5+``i^6(7DGrL8|Dcy?(0ZLKAe3`HrVDx2*-nNhY z=06=|qcEKSOJzyN$#7LrcBkNHaUr-Owe20HvC3#4wFg!ge9bdLmrDfKnQ*`s=r>{4 zn0ebq*@S}6Emi2D!zTg?i$Mb|32YkXr_=d&2GmA&gBH%C9N76n$w)v3m56s-%EoIO zf4*uDSQ%OzL2myVpUZzT9=ckOJ^vI%ABr&gl!Kb|AKIIZ$?;zUR=}!@j9j2M=IJlu z{4%HiY!EVE+@3996YDzmporNbiX4Anvo}Xa8&M8DIxHd;Qd9r%ON_@x;c*gkud)IL zvJBC9fpYBL{|a5{?c1HymVM5F@K$W{Wu^kYb*BXI4sx1vi%Adk&~%n+ib&xg^02#+ zC!8f(zE-J+{zk(ZAs?lFBO*CEJ;rti@CcwzpnqB(%)R3^By7>sM&R6zc>uWmOsS;x z1i#y|i8%IQZ1N>w#tT_~Rl_uey6tef7RX7C+sE%!8c|+tT zp9wdTb7aW8NtU}l9iRO0?#*0Egqa1e84zRP(jNjVsh!Ue2vL^Jkjv0nq7b3wsD59P z6pTPS9fO_dH0$VSR#1h9(zq{>arHjvzjGER3i5a>@^{v7#b76<2diyK^B$##K~n2p z`KEyDCbsJj_#GNX#fMkVBYCNfG^CB2x2oHK+_p~!o85FV^$n@*;iVU2!Y9%WAw<^? z+YDRf^&nIUvS+`Z?U&orCx_bx3q1$fc5J>`F~IzWPW5o4^v?CWhKK3S`K5r>*~eOq z8o9H#UW?K_TW!#)v-4=uSp))1celj|o}LeJghYJA30$(jrUh0JY!QZZmn&|lvOvs| zp3$;}X@V@!E4}e)B&#_0=>iFby`P!_exof?X$Yca_7oL2z|tE}hMWOh9s7BqMB>VB zO91yO^ldEH@Cp>F2vyO7Fj0U6pRXl6!D+5$W9)a)f4NVfp^)(HIxFaRrr8%iZ-*UY zlo2Qg?N%{d9D$s`-3K(6Uk3b*%^k2ftE6Z-?Q|H-P4$N<-Lz$+Kj!ZIM|4wZ-rMJ`6`Jr z)on<{sNqvxd2Cv|qAPF9!(Xj@g}k#vSoXW0(U%%Kd;MyY?ZtZMA;+o!-zELiYDB*c z1-Ckx`$ySWDot{6S(}~*r}tRBsVg*qnq7+5V6A(?rV5#pp{I7MFd_-!1o6{lzB3-& z4!gNSlfgtQe@?Jr7tW?SD~z43<4BU*>g%!1ueF+@;`pOUS1Bv zH+TcwoqX26Oyg*WpVas28Sy6bwsh72=Ka|rQJ0^UqJ<|%p>LMmrp>%;^&(Kw=$MHE zpj_YdiL(y-pL#7=61XbRHzt1C8%I&~QPYz*U|x%3b%k!&ges~HDl*G=-nz%nc_2~q15$hGr4Vx;g;TBY>=v?3!g0{SLiSw7uatr77e?DwA&>;*uWs|F?Fj z%~yc@iH&?A|Gl@uiQ1%YkTiFP=t>nS6^A+lXCU<_0EyC&urgr!v={{@T9iBcSoHAy z?#D8RAk|1mR*sLg)0bcWcO<6%Ab8 zzA_gE@n#hmHg43JI^ysw)<6`Sf9S0O6m?L+swPWyO0o%EoR%I;y<*qbUhY;tRr+PzOL*I+ue`lyM=kob{Cw&(v&k8=ejfbQvk@uOr$c3P6m@6qc1oWngmjvP ze_477t6|4%6-jBKGz3H^&zKu1(`v6~EL0f+m2xcmukGj$CqDbAr+5S2_c~^fe4b!0 zqJ-qnvJff+)6ikXKtQ=yHr@U^BNZ#k%n+6}&iViCy91o!t;~{XB10ZrX}o3G zT#Ht+#PEGj^S4qY1SQES7FIHxe$vX+xWG%!>F`Sx9~um zeTX3mt^R7SJ~t+0l^5FWQ3COFk8hmjwaldhU-=-2{JB!k;*nlAQp1Il_P$d$J0&vd zP=STWX28oyDhBc>fAEg6?x4IhPv24MIYZ&FX!V@4kEirQA#P7#EqPf!@s``HIzo;W zST9~!1TA$^l5u!Q`#^`O4;qgN*)-W^i~heaVCYJkuUq2E2F#wzg@Y-8>?h1K9Z6T_JIV?gkVHu*_)xsm zA2M6LRyTr~dd0f?%ydG>x`;j>WDWC;`Fj+Ug6zEauwTvVb2zXw?=Ao_!TlIbg)y76 zB2Krxlt=OP@eFm~y1=C5qlCDXRtPcY3cIMZE9)eJjU??#NWk6|&c#~6>kAw z9#OyQy%$_m?3DqAdfC5D5A_*D_EKuPTiLeOII^x3dpX5oz8GIn#w{y(66sWmVri$I ztUfOLMV4)QY3UIHj9=L=@?JcQ*#Rd7^1ukKkJFndt{wH()it`f9 zDXw)*OFc(8KIgJXb6%$cNu?ZWPLuuQYW zgf#z|KhRKswWQL={-9n_T2>-&VFKw_($8N%=mXbvGmFIUW(v-oW2V+erBawl!}m8H z6h5)!yGh@dqx|f2KXy(+TA(W}x(LFj!QG=Os7yJMPm+J15~6F4i~vch*>XKe6MLvo zkknSwjXz8ARUqR|S@3cch4XYJJP4H`Fr(jV=@1{mE5&xXCv*LdD5G<}h|O)lTH(b8 z6`8dR1j;!|Y3YDUPDyk&goP&yQ>ab>GUHYJ)+^^Q+rt_U`aW{gLUp zo}p6r-{yB`$cSU9@%1WGj-%>cyu93YE1o{H#nH7n^?T3N@?7*qIvWK_Bn6UUl@!~UM8e`kO-Wm_j)N}`?hg?D z2XuJ=CvN%8;?Lv2Zq%S>d5h86=R_}>>$5;f$!~{6`043hG!`V8Qt>O#F|(kO!tPKD z2Y~LO6;G`l3V!K6{_ds58Ln+7G{tnQtld)JHPi>th+gDK{V2V+m+89`;2Q20m{e&? ztrav@B1Ah(F)Xher74G@d6|ev8g-sUwfqK26W-_LT(atf)R#wdLS|vALY#kgd8l8) zIVmZ+XAzH9$^}+kXp|I}HExvhR&ZQO*yfU$pcmo_d4J`Xd@<3xSkDi-`qYh2f`PXL z(KG29a{g3%bfT2sFUr)LvNC2%0cSC8{Aho7ihRFSTUTJ$;^FI0x??YlUr)YU8XTCo zBNq)K&(?eKhUv%6seew3u%6Dhp)zHG@Gm?&NjkI!ekPR=nOTZ!`^1-fP=%K}?9mMK zso~?kv-U1@0}-`7bF$^M*6I^0P#AwO>*j( zV)5^3uZD!fCz;rX_Z$np*5##%RZ1+bWQqk&)1AbvPG0Io;#YU6tp0lXu%znz)Q;c^ zR8i;uz*Jhxhjq!w?Wp9>)kUnXQ0ixCO;EF>Mt0&JR&xF^;=NtT$8G{=;@VE)f^RBi zLs74iwVA578ZH{ zyo?v>L?`}8?HXPb&fI)JpSt~oUhm~%Tji(v61f6H>3Rcm1d2u&9a+*)@{`m z({W{gUrWzS4X?A`s$fdtNww%uMwx(~Ra)`t^|1>LUqfGCItkFCN}a8ZVmtcxvA2nu z6$QYBhora$ieUXd&aVhiG4DeFe4qIKYkxrd_gy6uE_?)Ti)7o|ZkJw>m`Rbo4aimd?6yi|211p~7G{U5(* z{s~7U0F`cBp$hX?V~U?zUO0{ab_QYU40zW!N*68lz(kuogJhAD}hggrUT zh9V`ESwG&Ys^9GU+40B#?aJZx4LrQ-Yn2fGcX)V07r4jUSs?meb>e-!%<(k#Q;fkK z!(x_*k9SIV3@a$nFC47$3N<)m3_pFW>#h8d<>WY;@yRS=3Sy$9_kHGpwc1CPPbTG6 z@8hpe7q>6G5_+pRoOco1uyGR(d+(sV&XdmL8ng4&*>fMS)7>WPQ_w)&0At(ynpiV* zaX+J(H?wUX8;H`dV3B`|7wl&8(+o%)c5VBy@J0eq$!Fr`zN{a%9AI{WfZ+g8Qt98_ z`_X8m$Hm_D)t)P0&E>-d;de@(&yLGB??xbR5t4(aZ(jk4$Zkj=3%6stV|r0@&T22< zSay1EGGRX0ENn8N_LLF-79m0kzo*P=>6^>YbwZW}(62e&lK_&@GA?il1)EFz=bNZj ze?HZ#fFq})ql>Gh>&vBKAOiXq7Kmx{x=c(wZAQPwj#unij1G5IG1(suc|3i@m*DV$bElujhm}|By4w|CDBCMx1dtsP=4pn(S;t`0uO#2+VE2 z7^=A#N@-BWdEzn?{V}Y=ID^~8T+QX2E09Rt6ama~STYw{wFXAt>Qyo}EO8;uppM2@(QI+?qj0#nNdnF#ZR=YwAdo#tPk z=3{~IUkiTs0usLImVVI;To5x&FjBh?)ea=L6xm(irjG0bqYd0o4@>3D#8bUK+63-e zTUmMZirUp(;!^0bQb3S!{j|9sUnY=nxMjS%TF+Ohb-K?r3^*?SVCe#v=`u)QKk^#`ndnL7S)X|_9ofbBqUhQ6Tlq~mq{rarty1aSw`fuX(-~ErO zGLHY|AqZx=sHw>w7LZlp;ViaY(m+&yWerw#O^x}nJkwz}0UzDc)(SA6!=w;HcJ-4i z1NZx!T}sMhFtgtwbd}BT89l!1(rEb1YXieuGM27(mdxwdaY52XC|l2VDgz(^?=q7I zwoF{AI>h}F7u5d!AnWbWLC%lF^LuYxLb1DEUFY?TWftdi(;2uKB^Ga7Q{VB*9b3ya zU7#&4(J2ERl9X8|Kv;LiyM=AL(n!0NqijIjlB@iTdR-8Mx*R&dJ$aD25*B5B)z)?1 zR)3BgVRgcxn|_{}s4%}w>P4>2j+J}I&x{beq(A;k2)t`5V&Gk?1Mhk$J26Zvvar)-4^(IQ%)3~`UAPbQAQz)tAue)Tt%V{m@5bXA|gm%L5*`i)0JDX8puuTVX zT08l8cEbGhbmwv7Q3p}j{2MDadu>-c46yg%GELI`1Tw!AJj6EchE89l&`GK?z?H&h zL6Ho4iR=C1o$y@sAt<6(VT^GBuwvJ|Z$HT0gV`Z1qBE!4*F-&5XQh;N;q0=Y8lSi>4$^ga-Ph-4i9!gTd}fBflacKt}4;aGnkd(imEeB`?H zx`gfF05C(!BZ6~W;aDYa+H`(}b{NRqfR5YeZISu^yh$?@-M~Opju6#k#!7Szo9*gZ zzH7)K`hghLge;zkHCG>9J$;dSJ8bf?TpQ!V8~C?8^=>SGmlL$-Kv2p`3@0^6Mn{Bc z0ZTb%x{lj0=-J#-*`u-}+6MGAC738|>2LfJgb_H4r%x~=eb1eJCy7fD3Q##pGN}hz zL?P|Z`Ro<O9CXb>&g-zaM{QWj23(;s2t~|vi?V8W;QKpHeuL@g&KB{mTqAvlq zokf-Kv#qUz`;=A^p~rq*Cw{}ums8HUyXg%=5}KLKpF4iu7WjBSdP!y%o=E(@R2n#1 z@;}c823At>Nff0wC=;QNYDa**nRfrINbao27jc?*YYiA;mR~%(IHa`qk!3o_8sP2v z8{6f5MqjhTq&jBD9*7H(G5*>2L=|7!nRP^VFvnj<7PQG+JBZ)okeY9n7>{=YU#je_ z%VvI71C^E!$rQTUv(fh)Ci|m7l9DYt3=xbn-FLOK^1y_<$OaumsY(});bV8qCC18t z#mGbGs#9ZcU!TtZw$r_ zY1t624~fdc7E6HPI+I-j8AQeNMq~Tzc|z-0fgqo7)8lSnhQI*vBtl{S%I*2-U4zA# z%yB<#XBEMlu>|(JOMeo)cqQJYH;YOVzN&xgGKaCBwq})o+pWcoyY$z#!}MjH(}X!B z-0A``+ZwW~spAS}N9D$s!NRwEK!{jYjB*gaP0}iYxOBeD6sW>H?$}=T`PxX2U_&0;J@9WtKM$E0B$2oJ@^nqIww?nL{*h{|Bn zR+oQwn<^d$?(64Qa%wSjoS|!~V|9Q%Z9leuv36CR&_G=3FJnJT=^$~QCO>lW2uSUr zft*$vjFj#iPwHtdEqR&Lm^>ou;_OX49XTOU^J0?zlED*~Vk0e(r zTvwF=Dt!N-u_4TBuTuu}rm$&rl7BxJDD{^HUNuv8ZpA?#hcHzGQK@(5@;&E|B=Abv zFQ3jjdm;Ryd1~r27@rYfJrNR8XXo~5MoR}@dK?-b3*2$Lrg}Bdb?_Hke{SIk4Y=CD z&3feC1x86p6@+%5Lgm5#PkUDz4`sT>Q`(gFkiyR|k-R6YwMR2yNXhZiP$LS_B5GsR zOj*K+s74t(3`53@B&r!Nts0FOPA4zrWwy;*&3Y?igj6$0!{A(F?KwW259iDIa(>Up zXXbvn?)!P}>%RWi|GMw1fXPhAC;D3BKSTNf|LlY_-a@;KS}OqE-8icmyz* zQ~LZMi|dqXW1}Yg9f@}MPP&gZ{&_mxxiz6(Ai+z;vTJ2@N~@>@-(-=&-#sXU#oSTB zuWjBh2Sx`RQBz+;dr`v185fLE%9+-i&^}Sq@OfC<#rpep zY!_yBvh*!K5u_4D7mHI2X6yZ$vfr^C^x&Ih$7HvPr|!G01-A|!TOmoKH&ndinDQL z_-CzBkNqa0`<=dr>}D0#o2B+Hfo-DKtE3aSatuwMY3wF5NETO7#e_t0Fhm4=zH7!7 zrV^7)EltF+3^TCf)PrJ`{VhHg3=r-m(J4i=lkf>%Go9|EbIf%)i z265?eZ1GFxMl9;awryBxQk`_DmTR(?coVfD8xk!s)>@!PD$j z66viuP6By*k{p?K9eO90R%L0dS_Ujp=qI6)Y%wFrN$+C<_bTGtU#*;9cDn{(XMc|+CC{7 z%e{MyF+Gt)*As+f*pH1}lCpc`FtsPu$Y;740O273gk#p@XEtngJZ7w+ik-`<(jTH~ zen4NOsLbk~YmTH4TK(y73samdV7aucUWXq}B?NkjB9C)rXZv|MAybdhQ7WxhhXYXC z!uNyXERkfPvog>~C7KU#V@#%jTnm!76AqEGQcHi@N7J3pfRLcKtG+ z01;2xB(iLxH~?d-0GknUBsC#abp+0ZOJzYCqjF=SNXLE8L0-`UTf+MH`960*IB zs;4_94CrDBNll6L_u8*5nPzFeI&qj&RKAfR2A`;=&fOjq1{rgOHonX=yFvwx=E0c_ zEtH?1Xt~pao>*FnV$N#7lvxQRNaXxzJAH2IabiOI7(ezovnm)3n91zWD4WNbd%yOU9XVyU4zB-MPIK;T z-ic>|DK$;>I2Df}fZPv^mS_g}2k^)Ulwy`rvqf1z%Y>;YCJ?2z$b( zw2#ljW$90MDO2+_Ya3S6>ZV^lV&rGX_5I3O80NP5zs38Un!7wZGJenFaAO=eN&IzA z1zUAkJ)@|$O^b+S^{IderwypDb(nU%zUaP4?r5WhAEtDnuzyFiYOj~V(ES^p?90xe z-wHr$Pw_j*DY!(IkH^{wU92F896v_P-E*O#8}^{Gsc`ii7L zkY=9`GLC3EB(B_8A$()=~lYYgZ4#N zm>MkCeX7_xiaLxtcathM>s1)6;|q>38$^Aj(*BlpV8M96bht)V$wyuYwoLto6kJs3 zYUt1iP)RZ8h&@}%8wrMb46YHXz@Ci?H4+#FPcag&C#STIw zZh$=G$Z)|y`n^hAZ*ZHg2079*7*L1FD4Da46r%8mHRP0Dn11Lp2v&;OuW= zPIX=6MW&mIJgQA(pTr898*7yZv`PB6=ss*-3?r3CB(BQ3wn!u{Ty^ZPi-z1-Jqwjn ziBev{@yNKQli}Q(lWXJzo$8kZit_BFGeI_q)Cb**L?yiel2z0Mj5=xhMMJi3?hSvG zD=Jd2BwtP^E>;52W(_W^FieGx`qB^bfBa3h(mARx_RGh*RIoA+SVC*2Z${dmHRa?c zkdXdY-j>M8#-)P29>C$|@mVg5T&A3ClryHlNt!=7i~Hkov4MyZi0Nn(b=_Ppi!#Wf z5nN*`AJ0;YHaLh_7?F$}ST0K{$f9i3w`rNk`9PG~NMfgM|Es2wYOfQ>lA8PDa*^9X zFFuJUA4W;#w%~R*F*!1Srs;r8p@Npm>nt#htcLXdyThx8Po&#odd0 zaNhJe_Ppm?>zwE3_v6i4A<2Eu%sqS0o@=fxe9+T*Muh(e9}NwS=()Pe3p6wwI2sy8 z4FCsq#VeOy1r3exxr4H@-g9MTW<3vAI|nCQG&J=ODaN=UgQ+=rZ=1vl3~(R753i~y z255wZo)HqAfCXUdfibTZCi8&mB7;oFiX&Bb(F$~aNky+NK5(gieG!dG&GV3iUVCxX zdU&-cvz5E#Kk657*w=u}#SGHH)Me#X@kHk$qt;CN(rJ<;^2DToQ2{1TCWPT4)2fBg z(Tc&;Q@aiv$f85jzc4mWdG)~fV5zFhoP3O~K*lmc)pw~1WI{tLU)C(>Mg!h`UbOS7 zRQXV(pqyxpK2p|F3b#AM0N#s!{q+LA8O$eZq&V?5xl@~sZL-i&h0i_>-C?{EEn#R#so zXQFd-9bj0f@O9Y%pM4-$TNAzBqt~aPwyIb9`9fbHUGu(aCO(Ryw_DXKv1DR>HS9TtRI2AWT*?fUj*i8qhjYUxjm?f}|t#NCHX} zI!uo2#Z>!pHq{!<&OQm>;2bQuMvXtoVRU~^z#p8>!2OWq7-yZiO+ghs4&Brtib zY0XpAuP**1ry~h#_5YNb|KU}}-PHU;-%50`;~v)mMY5F&UcN+ttihbgoPDyY$%G(p zLEKZnnzHP%kL7oe)^9av7nPP~R+<%e)3ke7Uc}Ndwdhn{gpR!eR?m{5?18m55hc}`OI@aDft62)xm%^Qnoty z_3>MVvSuI}<#s#g02T<1ZW(ZTjMd5vfCZ}yp|b~3B%l{|@@#dS^I=za$W+k040&5Y z2FLV#Ei#So6_VF3_Y;RWlziFP4gD3)REK%JuWyHl5ZYo83zniffC0?R5&76EqMkW7 zQl2bCh1Ff1EHT)Ir6H2$Bim_$YroJOVk20f2lm>s|zmqhL7W>jPW^{(-i{28)^V5Et8Apge=7R~i zEM2~*gRDY}4>!q*pn?aEoMV+-cp}+)0ilDzm1Q>DfQ3bFfpt5aJH*Vxvsrc}}O^^t@NySiPmd+lIgD@k|oE z>Ii#u|Hk^G6M+-`6Ur0v6QF9E)ib;Tc0H_Q$z;l8=Vao6#~OmRk;TtEKj9b{e7pM9 z{mb+%!MW%;<9SMIYHF=f&?Gzm$4S;w)6y1G`&!1gEk%DOvK{DWUuI_i$o>)PmF<;v z5v5Z!H_^GBab|Vx;Y)tUeusXSBc?p3omImB)8dP-W&YOhX2sfc-zR*{;0 z+sNab0Q0D_7J+AJMNJBI7RZDH?FB{I@Z1QC5Q~qCPg=}cBoa@dq_{jzM8$d!Om=0Ad@K5Ed5!Q=8$-L zp6FrSYg3rXx*5Dy;_aAye+6MBg-KbhjNKLPU_!SYWz}1gA`qsLosqDSM+v`?xsmcD z@#Oc(jY&WF;hdiHt+R*I6gO~*~Kir#lH-~$3 zdwEhc(ryd`^qC9?lH&}@4EB;@QWg+asViwp3Fmi>dofZs5?hi}(v}{I=I_l_1nA5g zd&zomVkqOC>HQ{BHV3@;-JQMsw|m}2LwLO%A%1crUavOm4cA6uZuSn=EVf&R@5&-t zd$Hm@;)&zaaz}FCkNS=}TD&XgDi5>Rw-9VLZw_0`YIeO6de3qFerw{-Y5&xy@y^`p z+?M@r+Q!tf_ry}W{8(Vd!wi~|0<;|w)QNtG>5mzWiNwCATz~WuSi`&h2O4pVIG*c4 zVwvHdhSXv4DuGx`Sma?`N~KCZpJPAABb*Re;JMD+ik#`Iy@kE4m4lV>qtF#s$>8j( z>@`x5$~zT972|>co3}RR>!Dn`-{!w~7?f?DM+s)U#7A2iaE(x}KjW zF>?B_4{JXNjTZ05bv)_@K~B{zMK`+o9kL^P-7yMwla>jc}laS<;^e3ne$Nf_Y- zp}v|Q2e$}6#BXysxsQ9d@J#1C!&LITM>N9W>%@q*c#eg{T_0f%Z8PmnHNfPLNmG^F z2^loDxx>lDOZfsVuhVigkOM#tr#!<84J!%L^$39&ZOIH1jhH9RrBP=ZCrt^iM((Hx zo>-rVZ?*BOb4}|Dy-K&JFN%DXZ1_$cE+_!Z%XAQ~erH$LrTT(fe+WOj)@3qC)Tq%s zysxl-HkxQT{O-%V{(b2!___LX|Nf>KhiYWnPD+%f;D~!}^}CUzs&7?HTdR;Oi1e1p zu$j!^%KM93eu4z-83HNlSw1*ux{1qoY^3{FH+FO&ueYG3`Tcd)nX44f5HFSC*?YP> zXUJEtuQex|-ik}k79oP9{C`Y$YTtE^j~a#)pZ-4h{q#3e+HqRW!{XY_>|sRO7T=-i zN|V)u#;V5g09}ca@&2pQx<)6@(aaByrc+guMy38OE@drDoL z%~<9K$oho)Z%)rFS4WIu=Ssg@e0V>Hy+lw-?JvN1JG@mFYTjyInszHtYe`%`KPKCH zvz_vjm$OXm)!q3c z8D$?0zvg}TbyL04uy%?W7(%8@aaGswMWYrG zpSshQhZ_;5ad{cq%)5_gl^019MRB8*d51*QL?oKf7rAeFgF3=D*U^qVaM8{P(P+|E z?!i&l*+pb3{$hqKNAlov6TQc+Xf^FR4dz?)xUU7Bh=CK}^7-N-82Yb4#~KHf)U}Q8 zn_oJ}Nmdq)j(8zx>I`V_6NyPkoaq_wYlv|C=z`X&e{E@g1+L62MDUqowI)7b ze2NY97KX`bp?nKGH+2&T8XDE(zi;&CFW8UJ(9m-oUV`3%w6&zHU7ZEMHm+8-0#Ii+ zRBJRe8K@NMtF!GpFf-KI$ps<>m1X@$3n|q1zn2ABng7w`oue!(NL!Ct+110A`Kf@Y zfDo%3J~K13jE9Y#)C(20fA5a^CCmEe-8(laK|ybCZvk%+0ap)uL19TrNkO3}f={0C zqgwDod|cjvq5LiowtpMsKgLn9g;;wyxV>|5bz%NHF4)S|^PMa!>)!|c=j-3*X$y7u zuP3=c{#`7TfP#PT2nq`b3I6BUsID@9uS)4TKy95sDh|%JE)di+-_6 zx(HVR12Om`Xm*5=F){dGmSZGXzum@Rp$jv|Fn>u;po)2|5E5ylra+GuB7t91lL(2+ zxoBH_JNemVAxEbf=@od6@){wdGffEN+LE`WuBC<(_?n8(0RVIsW-M3`5Qw3q@IU;} z0%A<4fV6=pJVyURtA84dnUxu#fvNbfHGZu`3Isp>d{pB0FHHu3PHOZB^M9Q0f6atE zDhN=57iJko@UKZ4SusxYzbNBB22a<+Lg|F-mgm*KCZ+-`rRRUu^uG<>4nyhhtHI&_{_m#q|5MX0l)->(jS!K@94QnrXbQHx137$6k$Tt-1*U5O_&^oR zamI_!CyI1Qc*~nT(uJ+nY@agupN$;2#jB^&Bez*|ZeaNh}RFKpHo%Qi2a# zkO#NrBucNJu(;jq*F}Vq$Vtq5%{f<^I+oTNO}n<;A!Z%R%}s_Ubme8~{q`zzmc*Cd zpB{CP1n6}+;gb6Q1L9+zEm!t35_r$MKTDVP&b=H|rr#v~o@MPvq!ND$ zk`Sn=O>uAUy9WAQUX2DWURg51+HI7UuXoEy%Ka905qmS$*0s5Jd-f5QdB?j)*+h8; zCc|v;GEepzwsLcpmX71RVXp7j=)mW{jSt@!k?aHOcgva(--~h|$<8jG<|6wvOBtLR zH2^4G3AEjw9DRSdepw$da{ifX2ai|AIL-9!_oEnl8MfxjZb9Pm_Zz&OjuxkxN6AGp zs_c`=d;W%||19Qfp1(5kT$m0@jkfNKuS^;H3lEtcfj|xoMPWG>{#xmh)-$QzyH!$H zWsN(l^hBo#+Rt4&fog6>NS3P9opi#ug3YXH0x$V5YI_)gXa(PtNd@=wM({SiXncVv5 z9fnVV!KS#p8ZAi7blHx(>N}x(E&ZI*AzKgO+IipZ&Mk`K%VE0zp}yL|+dumsIM(Ud7!JV~vKX~?YXme41| zPtnQ_Zu!6IQE)c{iX=q)kO=Z^Mm*bGq&9kG zw-ZLlx_CQdpU1$T=h|{UdGu46Bux6SS%ioFgK+#&{Xyeyc&ykgSNI-t!T2}60##6K zpj*Ic))H1^6P-&v%U4-j~_Yl#UF{fvdjl<8~R@<-bhKZ4Qsjc8ChAhe;o-1`^ne_Lusl%#9I zW8&m7XzLWXgOGc|$#du)vqI`)n5sJI$PqNQJH7XWw7dk&iE2oCKReY*b+O>RG;Jy9 z)31qeD9WK4MWYG)+DeKM`V7TUmW6Y^(F^NL2R__&9``aa9iX=G_*)^Q{M{d2lE3hD z%kYIhG`zU$J?x8n6+5ZX-Fa8r-CSE(+wF!RI9~PwAiZB>V7y2ics-G~IwR^p1Xzlu z&P_y7s%Rz*NpM)i2D0tp4k}={Xxhg;`3WW6w2ZGSF>rqA?!X&Uje>ooZq^^U(5Z;a zKw)IzvvKQnC-~3Cjk5H1W?)5ZLn1Jp0>JmGzM|q(J*u>Bu~A5uTSrxZ&+9#S@;EFE zjZaHxuX*oi4Kc#at>qRo<+iXT|8RwX4SlTXhM!wE{Mt=WCg$dQwNmd9+y3GFD+3jo zn~f)(@khqCPx4xNg01)G<<#N}?+mO3IBuyO;gxFOsC7g#dN)Je(ik*e0EiK))DAe1 zus>=(_Pv=~lKTcv8I1(K9uZ#sK5Z4v-*fi(PSUz3W;J8QO{jS*(51);}E4KR47+Q_;ygtFMA;=G&l zg%Zmya=VY8&F+mh(0-?|`#J6@G9w6Y=*jHv3xmbp zcx@72e50~zHpEQey2FZi;JqX-%6T&Wv;001Rlwj1aIYd6F(f0Jp4@g?dQCCtq%}o zW+o}Y2`X$Kh5jNd-p_`N(2|EZgc6AXT>a+e5E1gXE0Yiz`yG^pv_5Ons+ zT~r=T*0nx`|CoK-A@hyL%*H5jrS;~3z>b?i+4H4^L_1R|79vVSRw5}%==opRWCK4{ zfTpAXw;!Q4tGTk+cbE2Uv=j8l%qk&8fw#SZgn3s(_8~rt*T&J^O@NZcrsI_I_qGb? z+B*>yJ1sbk1^_J=EWlU)(FG(di@$&Y!h2&q5?~G)e){Z(E+e+mrXiigAr+gL(gi68 zRYtI0<)5n;cVtGd-+UxPh_@cc3I*QZPS^Zp1;T_E_`L9+h6W}}E^T)gGzhAh4F_F6 zQ~2qp>+oxnmoT?OS!8(AVS{w;xg#m}m*W|aLH;R2^FaYMwWj?abBGq?B3i@emswY2#h%9?PBbg&F;=fs!!P%ICZSEt@?NGunZ;2^-zmr|AaltWDd&AK z|E9tRx!~QK&@UJ8jc$b}<)03%ih5?$sqOx!pL?E|SX2$jo-1Q?Oy^vqU*RUZ;)&uJ z1q<&5@nDpUL`8W4zR*9KmOO~J`0|#T8oyOR+WWaZ9yyY@8ws4vQt>G0-DXJPVY)+p zzZF0f*DW@o&>g{%)C1< zfpvhy<#MRC1@sKm_p@Rt;HLY|{r4+?Pp66h-rP6LE`SOFR%lo?0hDaDbik)P8$DvY zId(i}K>6}^D)7$uWvf~G@1ydE>+-R+l=pLmNxE{9BU?ZQc9~SK8QZk&4NxksKDmT+ z)LEH#3-9}FWu~b86KxsC+O08s+^qD~~1%UAXrtd?{PCTv--pR;RB-_ zvSC!UiaTKJXlpd^9&$M|8bGBZxsePC1Q7P&?r1H6(mUQ1)ZJao^&UQ|qB|9_S@7A) zp&p$>7-Wdl_hnH_3}p+8@Kx_U`Ac<3{?fAi_q8bf#IcK35kS-9CjNt8i z;VH>g?(4I>U8O#jETCUEJ$z0f4|kW8rTaFESlFxr=hiPFpro%~*}j1g8xI zH|Z|)VjdKMNspBkEdL3_Ue^HlR#-@HTw0x#2*@o~2gSaYp02^;o-z5<{BBv-r1j#0 z9`9N{G)mm174=eY$&!XHh{3V1o<1hWH%&>~B;R$|647tPgMHm_i(PB?Go>C7#kK*1 zZ}7>#5Rh}MX3+h4Dt|o|k6%3$UAZEeH2{kn7x_Hydw!DfM`M9IHBQR2)l$0Xft zNs95VVx;29U|(cym@40h#99oft$I|hNeI2BjaXOk*q03dv)x#N+XTC*ld(xryl|i=8W`=O!=_>C~@ly-UD~)Je4Cj%Glda(6 z{(5VtqYQf+8fzB8(JlP}8w=e$)aD9y*9lnP9Yx+*AH-(-`DQ#?<5g4jlc{H}f`c`)_EzWW74lKVv z)uPM#*W+;;x)hDimJpZsQEk%QyOAJI7Iwi$TFp7vmCBUI9fMbNqf39x^5mYUjpWs%h=m5v(S{VoqMgx8E~RLW%wH*Mwm_1M>&J5rgS=b(I$3634x+-mpz(t%Du zBUGo96tjCo`j8%Z5EHmLB97u+$8}W~vqm;wF z(Ri7YK15&H2<>ZIpKEWmmy~KkRDHtE7lwN*#8TUzMrgq$@G1>3)?|9id)tlN%MSHH zL}V*8T;N=&vzNLompfrBJ#^LBU?QF$g*vj4dYX#Vj+^2@gGm9K2i^oA@iWC zAG#rVH}g8O^hw3uJ(%`fg&vx@;*NNu#P-l1wDNW~AfZ4#rpGKow500DkeEBM(=HpQ zh#mHSqgkEL6c~+uMDKC>UsUH2frw^9=(K_eqz~$?c?UW8mg^aLHRdlXv2M}=uhQJ9 z%s_!);SYw3Ob?g6Z@#BYP7#w5dmZ^M#}LaT?eNV4N?^8m&Q_grH+$qLcO}Aok~S0oaP5{A1j9{rgSu4-^pKuvJ(^63(R;b)TKnIu0$}U({6!s9kp-5prC5 zoro&Rm^O_CW~ZrwKJx5Jx;zSxptKIT|9p4WgvV{YQ+KtQg-f2$NoD3l%8!wCB&;Zw zd)R$xLpWSn{dHU{Ns=nHa2@bhw9aT0FuC1dZQ+vVexZE4!l4eb4|(QNiEG4k)%VQG z62c&SkH-@EmCc9ItsT7sOE-W=+k4U7USe&^9+r2z&e@^fDM2+(Kx+@3(H|+XI4|ze zWAlpiLR02*M^0PEgGk_t9j8&eWi+9j*6|k}cC(Xa&J4LfE+(Y5^1pii-1;7>kP|^U ziji6dl7MV7te77JF5kb|xE=D$qs?ucH3%iGb|<#Y-4*N7jY{OUTy1uU`qUon1B^CP zdJxKcL`t7qwQ7I}A6*bK{quxayF=f>Hg_{8z)sHgUbHjpHO!+1qjgho3MPHnD3bat zJ^i<_nJoM?#&h*F`do~vzWKyna=32iH1FYXw1XmSWp&Ad-lcveJd)G-t87VG)m!LH zpi9>4y&n(g-dOq&KW5YFf#Yc|EF8h-2giywRzbIm>ZTSjZJuDV&U#=f7mU`wO zJB?f!Yd?+KLSLkonPZ=XDCr36%q$(nEPCwn2iImD*Dw0+c2h&>P6;=PEI1t`uVrYk zMx=J(_4dbiF-Q14jc=x5{^DS8+r>MOhfe_?*}scD1%;9lzVF>{S!w zN5DTOJU^N(_T@;RJjxU%V`;t`b|pqpWs`4`~@NBT_HqSFDQvjCGK_qwp!)4dx`n1A`Co`)N$$QvMq1 ztEfuE?J&LK7u~)&r0|*S8kX*73K2GRwpO^U;fIC|Udt;DA%u7U{3-!ak^c2>G zUVMy4rhE)Kh7T9ZDR|-dc$ zXT21-MkF5objnnu8Rd&d`y~e;GxJ>{2FNk~<|~3pnO|pde3b*XYvOA1?G{_1(7UXr zc_6y|%Ro!i{Z-yWgV4qpjiVo5e#Nh<1axaSz*$((DPO8*U;rx^p~R_LA~Z+1rRsSv z&QfmOVfUHX)TD-{UqAgPX{i|hFXgk;9h=K8T$yh%LeW&Vg%sRc87AqkO`4an1!?T} zKXku&D;e>%fEyw;l@TQ8cF4mAKvCwUH!}`&cfymg94av#<5~&g7o3luus_&&AQY zTDZ*IJi9+-8#Czd4dot^vi3#s`;r=*Ot1)PO{Q1Z7WaNe}8cd)`E?Y5h^2^%*H<$-CBd|6t*%s}l4wKoH zv~{eojmq<2YKuBN64GYY*jxUXmyFgQa?&;)vd9YBL&pf2@D0>z1jZ>W!$`;6%if;G z7%+~8A2YqXi$~$&G5u2% z>R%nTjWaIXgeQNeSqAukaTu_78SawmMDFsAyBZ(*bpxK1(JoL(k}?{_R5INzMyzFgTs6L!0aPQD6e$b0W8EH&QEt7tAa_0( zJ?#=H;M7 z&*eC{O046)V=D2R)W1;H=u}1<6}vc-Gt&P2YI(uhMrBtic8_OTvzlWpf~rx_w{Od6 zd&rndOt9?fAGxTLwxcUS3OhmyHh`WSq>5A^jJ7y7W9l1|Bb8rIqv%IQqt`)GV+~u` zW*~{qNP=ma26LJ}WK-VjQLg^GW?`fn*rPNzGot6a;4rP8#rB&Nf1X`df4!8W)+hO8 z!qBzPwp|+FE_L+6Uj!y3y87#~qj_REwVYHF&HgVai)^gFx<=7gc zqXnA3)n9&zUh^f9gVxaEQ6PWCa5qQo?8NigvuyV4^?y`s&vx$(h`4?jeYn*f8U*xk zMHe~?CCVK1c9io}=4Ggp@NOEiS>_`J?l3Q)J}Z^#mMgyr0-D*R*M!qJSjEyS7t?)c zOgafA)+|<=s$0!&FMH($_+YkY$zMukBJA{lfupCV%^8|8G4yPl!c)};P^N0egz_*^ zzM*A^mSwQ)-Nf+xn(eMRfRH>GG9t(6t9SyhjIa`bXR(Ewq;>NjHgH65mV!$VR@p5P^?hM{&cCz<^GjKLnB#X zlStoWof(h!^!`JX;^8ytynLa-c8o9@mZ#eg7>hAgK2&VCb0DCnC0CE<)w3)gMX5|| zRN$pg_*5neMx}u^yQ(32)P{;nuSzIgZdml>nkcEl+XpSrta$*nkKq7qfaI1Ee;2>T zJBqAz?Iso(((UZmpZH^zq`tP28_9^rD4!G&$ymyyt;ase6t)+>l@4K8BW~VR@_0#% zbtmKiLI7SuM?JCY%$!tXWQGrb?5B*ZnQ{i1(R%EcM6CYy*_mPbb_gNuH_=yE)0j5_ ziE`Gh;myuj(+@>Ra^I0=v7UvI+|0WSdCMW?y;C7kUN+Y0@(fNbc4`{w0Hz!3i!M*U zPaino(@T|>wx#P0)-5)pcw?ypgj}){4-7Q{c)ZDe3F7xd%5Y`TuYRuuqrH#Bx)(4pk37*xIn3vO&JKHf|`VYq; zJqDFM->Ej9{$xF~zK$l`F$jyUp&{rbFa13;|7A$AzZ++g9D!v7yNw%PP|ArtZ9j3W zRI;md*K`BGi|beNnc;eo%ax%acr0OI6aC!fkn6axF9a%7WR>lifeCoQK$*IB( zqc91wj6r~cI1GSeN3kQWDoD{HV9%Oq_biX0-R$ji0uC+%QVnxA^tq}oPnYx9pKm_@ zV0{kqa{lej_3=ZRj)`F4)gar9foEypQg{W1E`Xi7iUo(6a4?xShOP&9z$q7-0-MY5 z)yF>@*G=R`zcQ#TBC;{{QpJ&Ga#(=Qdf zYm+-Om^}AC6Vq5+Lbh!`_0f8Md6+b@&k^3o7TF1>7TWK23la`ZOh5dyM!_^GH+fnp zHt|EB%!teI_=(Q$K=werA`8xkkdK%ua#5hxE##RmA)q+9+Q-tJ!CJBu<}F4^$LA zQ_q8(fa#A2kD4nMuTLXZi1!D|?Oi&eo3sb;cylGydCS}4_1!)V(`+xR28-Ly&}_&x zXU)ZoiJeb8T_aVPAvxZ^(~{6ZTMg%Dy6ob8j;ILfvli5l_mzcC3cW2U#&q0E-8^2k z4)P&Tu54y%3Ai6&q*cvddgfzm@uY64RsG^rl03sb0Vfcutxv+lG)Ay`W?7pl&aD1o zly6gEGLI)e0=3PXb$#p^x8U164IMZAOAT=iQ|@1u((_B23vXKsz56sTVsi!k)cT6W zPQ4!0!o*gFjcxm8Uy|GjV1);x@`m6~ja<5fU*;3!a^^DSs>PoXGrxWDyHTv!l-K@m z{9WNGs*M;V?zNI!&CEeGEUE4W%jb zC2okX3{ULA4xk{ky(+ZbK3!L3=7a-}{N+OGJ1Ra!0ec5OUPWAG`9XzoKLL8H8nvxt zK6kOg9!C|Ua^-xc%|;6Z8A)*_11kOf*#;>f0=*+YhKzhYx%{7SjUiGlI+h+h!^kIJ z9T;s6=AG*nq&D;zjV_ty_d~~|3-%-xE8{}s913i^K+r56oXhbsfOzJHamRm!fbd^3#48d*%na!)^oay-cU9rpnUXp;qO z|3tMRR9J#vD|+qw+qNB_qC0oq!B}zeRzbM2B^{w92r~mSWT;W z+K;uQvQW6UevIH0dl%1_?SjnnI%(y%h2%d{y*+HGT!9RSd0^;~4X#?xbEZKs1s4dE z`dF;WNyc3L_Ik{c3Bs?$zNOeO9uJAkAEQX^P|mFtGH5-A!wA$&p4;<2QaP5zi()67 zK9Qw|?2|sDA3MHWk&>AzkpUH(UUf3vx(%hT-*)hPy|iJ5ryJ9pI@?4y4@-i(?@x2HCrC^NMP_M=N6X!7s`N1>Q>Dy zRh#F{k~tMh=sq1+)S_kkk^;65)w=4VU`+4L^B?D&qqs~^p-1SFID0HdJG_I|RY0RX zOplCWLZ*i}e-<37MO+#n_-wky_2{N#(F9Y{Yzp}rzN9kpCQHy!T+LLcf~-HJai?uIxxz}Y!vdX6Y7%Gx_f%n2=;?4I_dzSyn$oNi@0NIE{g1*E zRK<+~st1}cItxa$1m893TYzO}xG<$aGfgW}Vg?}W1Hh#s<7?`V7#8eOG?B~AYqNw5 z*9u!ElY$mf!yZ4v26yAok`wKJx?wpu2uQQL^O}GUjQThF;m9x^MIW=hb+%J0>(`&mY%zA1d z(#a|(0eG?Cy6CCRTlerZ4*YB|`zE_m0z>wD zjEbStBXpSE0Od2NPkj-^m7$4rsv(`Y<-oH@)vMezd3*$ZH`8ZiCYDsW{N%a7I}#~& z=2X`*K4vH7xna3fr!y81YfN&O4LufE_2E)dAHWVPP);xLbJu=*6`9CNsRVp&ex>oI zU5q^kse1j_V$tbT>j{n*vJFC!v>LS6SgP6?-a))GKCRoGg;@6qcK0#|S7WlhvhmtJ z+sOV<1zK=eiipOU3)#x~3tJ-VGJ$mqai28vXtKE!o#bwaS>Z25+UMKCWh>angRi8w zyF^wXfDHwSPeG1Z6(`RWx6aoYbjPb;R($fD(G60TO#=rEUEW*+5{_)8yq~i!Tj#nm zpbm0?K+k;|Z19E!qlF-aF=)j*36y4lfzOu2qeSY;M(EKj)7SR5dZ=`2QS3YufQV|r z)G%|UKFqeCbheV^fIDZBFkX53B|SmlpXAS}03;EbM8CWGdwfR$`7V{@BIooOXux^;iXA4HL7Qu2BM32Z4!uvFSp>xt95`DX$Zk}Tsxei^y#%wTG#?0p6VeQ6eCEJNd438#7CDHTd~vPUP( z$0Z|l?U0|PE)9%_1;rw%zTX8;#WbinQ@BH`+Zt=&hUVrtFVo@hV)K#p1kYCNX+UCfQ#ZikxRHXq}ixI}j zV_;F$deVG#y>>TZ`lL^k6h`U4VGZ-XfI^>~C8DwkN5Z4j_PM`Fvut6MpT9I|(<~M+ z`tO#v1-Q5R{NzTN6~!|b^`82U>2CvixV9cU!lCr-K2o;$Y!{vm)7lq}3G^-OkzU45 z2G1hDSBZ|nC{+k=+FhntT{U$;V|GH}G<}wP4C7CMqnq#Y_I-?6s>81WD7Acgj*0zH zRkI5_-5G`n;{v0Gq(;f{oPuhP|Fi}*R!{X@h6F%Pii=yXkTIR_80{?%3-5B5Jg3a3 zK7MUS&!E!%ETfHsh^jURbg7um3>a5WF^8e8^_dR zD#anBa6P{zH)Sf|TAYU}+r4Aedj?wYKJ*_8O1rz7vCm=X{Ahuy+;C^i60eif@-}Qq zw@$~M!3BQ{)kg>GNS5F!W5s-E^tqU+{#>pUe|)p_AQwIr`iWEb-u_?>>2jhn3ghJU zWM@JQS}TN5%SpxmfVh>zD5W$BTQW*~NclexNl5gji&b%gT|?cs{#Ge=?o@4BC9AhC zEVtL5;nIwIAu^QqLtQx3+$zq9hMPjIiDB zOx6)C#tV##hiiBDHwS`cZTHs_E<%f3yh+Uo6MZ-3)enyy*SlACxm0mdk3ZFlaDyQff{I-K7{=!VS zw-HP#Le-Xt=H@3|Nc*KLavhCVafDP@$*24v;QeJmj`+d*rJp=BQ>x?^h0TeLb1=6Y zUy-Pa9jcCla(j|E&RlY(p{JaA?>+vAM3Sv(yZRbLK$qura;vMho^ry4D(aEBi!m+G z+!rryP}yPzPU)W~f5VQ^)l{|m#mVO{bwDk9wt~EGcH;vs^W6m}AAZr1`UKTh!UYEg zP=y7dJwUlX^J9ZsQi!M%{a5ab%%6u0zQk5BZwMvdBB)z|DOwGD21XCr6|7c@SvM!m zdVLM_YUb(UgM>63nBa3%y<2TbBu5QuE=S*nh*Tk3v$rI8e>|QGu03vh1~a7l4L6K5Y+z zA~I(edBND>e;O7I8&fG9-)Zd;wa4E*#_1dq9~7*6|I3?I*GdBY`8W*yN{QzqQ!n(V z;C9q@U=Mn_5C<1-b}O`xn!skYQBL^~7w5l71Y;AT6mM50w365)Xps-f(FDKJ2 z7N;Y*PPEV#YMf)|*iI8o?&ySal^Gt9Ry8kWudenFBK0h(KfhPutZgg&ijEaul!ybb zOI@VI#6Ef7f#ZQMLtCN}Z{@6rqgb|P@=1WEn3ZIzHdI)n1)v(QFTdq4vPqkN|K4hO zcK9?S?y)}=h9`;_6)P7FE&&T}V;Ctuz#KKhN@S7qB07zrEi#0u6B(<>Z_b{q*bVptCQL-*-5ulhd_G z(R^9p6&^N12@-2W$53?hG0LRqb5*qtS^L7Ml|ahA@EnJu(jus$&C0eAR2Jd zoC*)k{=()CZI~gshQSsxyCP(H{c8L1%I7rvyCLmPjRl~Q69)^+^c%msX`v?y|S((8C+kwX_mk1C)3k+VCE$>7n)a%JoP zAWsT2oE4Z|(v(nemB1esou|6WK^4E;pVp)1sU+dtl=fUC3v!ojHOODLcNPyQp~zYB z?+KZTb>4Q|jC{qd5cq`;67QD`96I&sb1Q$0Bgs9sNCNH_$@HB7eyP$D%Xou`IxQTL zI)%IYx%U?QLB)K92>Wk3PTL!4zs8hT_ZcRDoRJgx9Pn4%z0J+2qC4+(g`TXoZEG7z znVp20oy`PAQX_b!%k48+z2?+?Ow?WqLy|=yToM5#Em6o4ly+Gh2(7sB2ah7woCM!z2qLxJB5jo9F0 za*B^jF3ELg)b?qWS2U|VngM%x&(;DJo;S0y6u?$Z+T>zsRw2C9-@xLgkP|TxXN@~Y zTl0JlY_%&}1KUjB(U9V6!4Eb83Jt&U?Vg@5GHf9qhlEBf$liO zEPsT3UXDaR?e=ywJ|sk`)X5@oylT%~4XV)t{Qk{R>l@wQ!a%S`I*!McE*XUwAT_MH zMd5PEr=5O8kPL|Q0@QqmYf5e4|h15y=bv0EP=A)`s<(&;Ir zT1RSL6!dW>&m57akn3}>T<+E41l5;N2grkRjEVXOPw%utVA^poXw$a?F`NwikUF(3 zF%D$()3*SVW@pGVIRijc46Q%!PZsYNFjquMqr;|mE`s6VqSiTpsv{XK7{EVr!l32} zc!pubdu7d?NEV)Y~YbeirM+E&_=!vQtuIkxKlBas%?0t_EYmZu}J>dskk?1$z zQ%t|@%|8!JM{v4XD(qV6SYb*58P|sPS)1muTGrDY(3yK8#tfgiY$UnDG;a^fMNQ5p z_IHVv8pQ-2i6wV6l62BbsKWzyI-eIw3f@%hxdNzk)}RH8gTFP_z>^D=zXy;K{h1jn zK7O0FD;Ir+*sO6ufJl-*_J{YAFqaQZ--`%huIIo=^cUOFH+E&nZIVw-joO+}+{Kj-%#cB=ODfyE`+vh< zano{44;2z5rEo=J1EmHtwMCL!R=yJ5&mS0}V}|Ob66nz1hN2o%ge;GkLT+c~uj)tg z!{tBODI`uL#9`6O&Nu zz4OK>M7{<`2q-i7-C|~+q$V5-j*+t_xKvJ%xso8$TH7&vymNjB1XJexgu2Q!fQ-)c z@WLV?P0#t$LiL-PpJ#6czA^QkB_o(l{(AQwr@K&C*-ir4hwU~`+IaZ_YN*#IpRYRq zq**MM0V7AQB#Nm*M=tg(@5d%PY2bkCGJW}Y*&}2bUdHERsa4%=y$Cr&yZ(W|a2Fow zGz(XgB98pu95ygPg!g?^WyfEg z`K!sBxymuIR`>_DLy7i1&w3hRz{%gorRtqyxNc#|)LQkt;wvD0azAl+{HsKp2<8iE z?@PW6Jt%9`#&hz zRw~*qJWCI@bkEG16LZUt7+7K5A3@I|ZYcxURwz~sdJIxqNd!qzB3^+HFd_|+xa&k{ zo7kenG1UQs!z(PK2mX2cCpQlsF%vzw1hb~FXlMVhJ)+C|rBrEKYs%>-45&Z)0KG>l z%jm|P`f8#aRDnN<{MSbupMA|;dXW`>RB^psk_8&G13t0jdBK5gEt>LW!f(2Bgmy0( zf9?>p;kC^hUD=k8I(Blxz-iQ_?Tmif_f@ATJ&)*2q;u_m>pJV{Y$Fe`; zvLoFXphCF?aUDFrM(6tRKg+<+u>zKzxa`h(urjsnXV!xio!`{LWGNBNiXwJvFz9}u z-;hJ>rb5{-2}0r(B@QyxCEu83`-dKe(ALHYKKhivP4q|wZ3V@7gn1onJheV??e{{I z$yv{E@>;6XU!~I0(R@}crw-S!mOG0K`-80vz4v0U)R%s5tzXXZjM3j}HbH%zfUboj zeM$nLf-oy455B4+#d@s=Mmb$ki}7X?^ZU~%w$6>oP2dH?m=nqMy<=U7G_y^j#VBqv z1|>*X!J%Wry?taue4f4}l`zMABFSEo9RonX+2GfJO-#A2wVEPc@LqTl$PO1`^0qn1 zZ|EW4-Q_!uVcA`}gDhq)QYx$|MJ6Dwd|A@G-czB?Yo-qaWLFSLzpV6kAY|re0*h7_ zIzCH^YXXGAd8i4y23UznLMX`nt^QO~iiznt4cU9o>3*di+<6bZcy!<(+Od|oF?{r8 zlX&83YJYygql)&>CZ<7mC+hH8Pd~N;<-6y1u6%BPc)#zGJ^P%6S=sp`D3s3K{QYeQ zO}?G;wnfSf2RI*PwE#W$_Z9P{M%Mt62s`=GnFSs@ipoN0YMxodZ)NR1%%RneP!wD1 zz;bp!DP&kGG!kW`oL>0b{79C`+&rHENs_8Dq;BqXsM)H4N#xmqKHOgmoCbBFh)~dr zMWq|VNiC-jJb|{~Go?fE8FW^0(Ylvkg?--$3j^VqP%lP_Tu0Kg<(2@Gx|hyfU4`G0 zCFFf|no{=;j$Aj*3zHyjsX;2^w$|-zfsLg?5>c__;>VELl9lpZZtzvRNG8bFae7e9 zdhf6Y8xvOPfJOoPJ*E%?K!vcc{cM>hzs-b=x60!YAr5SN!tNTr#K)p)%<`+QgnSiy zeK8wh)YwD}*uj1?Wcp9y#ok*}4vJRflh%zM-3T3{p>QU~9!t9+qc`GRl%axW*PIfy zM4gzzOD3B0hfi#bj<2>S*_{Fpf^iDEv(tvf4V1e)bjK~3E5PKtxXl5v0tAn){;2G! zJWFKvfv!cqybJmfjN^F~b_;o8qelkQ3mY1{47_c=)TY_c@7Yy}GFiL^N4kp`BRQ#1 zDNQ$gey^HOvizB3xHB@(zKNN0%uf1U(*^fBWx&wRArlBMi`)_45VAsAGHfL}JOO8R zs~2yP(YL`Bhk*qneR3MBi@Q5-yqI-ehfj`p+^I z6e|+=y%$$A1)U%X;-vpPmvdkQ(Q3* zLcdWQ<6qk`Uz`ZC`vx#MKoxv?`V&YFHQaHQFn+Su+#WI8=@`;CK`JJ#U8ERvD%*m& z**Qs}8&OYP$14o84?MgBRE^e;5He`V;%$m{Pu+OXbJY&;xFWQgZMHO%h_$;kIqkCOxo!8$PcM;$aB8APFOoOTa zo4NCx!$6m-3P!3w#`o=|Md-+sHaC+7`I9!?g_@o8hPi<((RLCas|jr!Ds%I1*XBwK zsiZ;15*$dpkjGz}Jw^6CASKODeEbG0gR`aqm8}Gveeb)0aiEfwOfuhP3JjTUUX3Y( ziK?+5_5BK|V0tDto8BFJIKcM#MPjdA#4vs|O=a)Om+ebZSn65N`*ShiB&a<72;OPc zKJn`D%4s0NFZ9S+^<6g*{%cj}nA6+eCfc~tglYjYbsrNBlInLMlUB+7LMx{m*&9T8 zF&d7BNF-kiPG}4pq>MY)sg=b;POXB;^5yXNxz!mD9``jS6e7R5A5flKL#xle2}s%? zzifMfxJ2B{DtKN%&|W8=TSrM5$vm@$o`%kkHB%Q6`KhfNqzL-$w|G5aGY|BPdD%ma z*H9)z)4aB;C!p-c9OjcvBCh8avXItB9?niU)#Gn!HAMjF2^^ik8F^AF6xcVIpPiMr z5v@EeK84FQ-TqPSd$#1K`HDP_r82Ue)>oqLQdIM4Ccd`SX15__uatq))6n3Jwuoh+ zzl`k7Tu6>RIG5w;pk;hnbgjrT9l7ZYjR^cZXR_EJsC*?}qOx3-)*GA5X`pulfcqsU zz_eyv_{M`2S$bf-fP}2B<24js;lfr)?mVQSeDBw}yjdAjBAGt;teTpS#q{WmABS{( ztYG6H%OdNyGc&QOP>uz`X}rwdh3n3iLF-X!d_@GzlR(Vw*qCn%5VJn!FDsNh!&0Ow zJE~MW?kyc@G0iAAd^?~EFdjtS*|hVcy@v3OKL9aXzFZw1Gkbse5aWXLJz8Vb!DpwJ z#E_qs?Q%DPKGS>!Cdp^!;Lxn-KomK|Y_2m-EY)}1hwL)IY9B1`Y#T1RTMPQEve_;! zrTY=bFW=(@yRl`CGJu_$nzc>53Bt)!%pKh~BSZsg=!CXAk9ce7lOB8|yJj5^U@SOM zFa$3a`=5OOZzv@A>CTXVLTy@h;2h!t2~-K4gCBa;t@RZoF2(B-e!f)cNvX94`USp01bv1J&Ph-))XR#vr&U7 zDam9>`#^dW-Eja-quyJY1yX4a*5)fn#i-zmtIKCUA13(BZoJ&?|BL#o^CA5>{kmBS z>RHV|AoSC@b=;?sM5gn_P)1-l=&7Y2}k)VpJ3-B&&J_!qpIQ87IWO zLk4*^`WJFguKE*5o_s{tQgpX#_IE@iH~-RUY)8tY?N0)!jA*Cb&X;C)lnWzBO z>4ezMvPZ<_oR-Do=eu(PmhP-i=9$2&i@&#z8LxSAGK6==!>CQ571pre^ckOO19NBN zujiLte&Q(^*TR)RQzgi$$H*63OsFF4eT zunT5r9u&)*aKrP*a@cCV%m{zr2t4kU2w_-z*F>r#%C7oZrpM1DD0Up0%*YcZtgWYM ziMNWq@7zqqbfufov2Vp)^R|hMqFKv#P4S~?ZX1hGLCVS1XeGd;9>ZXHnWoK=cKm#1 zT*5ix4kgrAyNP1EbbssI2I-Rw0zGCcaKE`G0xWjtTjFP zqe+E*->2Q@xGKgfvWGMGvaLuoQIc^)+)&r-KB;V?K-5{%a!eq3D9v_AWs1E(t4jP9 z_NyKo%TEc!XZnsOmGKb!C*lg{CJ|$UEz!VIl>3yoUq0>ZJqK{K1TM~{Ra*B>rxsOd ziRBu9>%aQS9BvqcJF^8BTzxd}_`io%_l>%Z?Iso*af+A*>_Sokgk@}mON_*fZSn0;tWiI=@!B0O(e||7B>Gs@X z`*o3OxBSNBJ{|3K?A4*U`to-C0Zu)XT9ByuwhoI`BE<2Z!0Y{Czzb+KOg?zxIkQ8q z^M&`u)DStXMWy?5=1Pmbi~cB?&)K2J{yIv8J_ON-e7c+tL#o!`O94k>l-g7+oGhNO z*c3l6#M~ntHx!MVphbPYdV9Oaw{7AI^5~jQp%Fe+R>SSU8QPfmBVnHCs5>T~6F}qS zX-3dmOc=+1!pMTbdhc@6B92HD-v0+cKyaMCJtV50(o~b8>m#=R_210tR7wpa#f-Pl z!loxDyrmf>xBGSGA&BWYKX1Uoz{Bq2*Gi&sa)Yr*0g$=o#Kk;rAZuhFE z731)gfoFgbgFWH3l0o}*?Grr^SL~%|?r1eO2BL&`03^|>5EVTd+amtQif7FXC=!=Y ze3uJS-Wq@&N-yq~zI0Xf8Dpdg5m3AFfdr*y@95~uT+teiY>4%M^R~;!^#@qqM>3M3 zPW!C%Hu6`k*7JVY&Hw~C=F~!E={_g0&e;rV_q*2g)dkEn+*Ma(V1|wzFyvsvM}G|a znnPoP_)teb1Kw-ez=2a@H(t_x_?Fx(5WUuz&rP5-d3P*-hs+sy7SD-{w*GhnK1!*| z+$}ex?#G~4&3M69g)dIB3amYt<6>Zy=A6V8X|4f4=Z0~-lP0)+ObDR9}gdp0|DNAz{ZNj1Vffu79^33 zKd^^r8Ah^DsZbSpxPO=@M*D#G$q@*SVq{}_sc5AP&L?sZdx33nvonE`#hx%8P&&y57XrD5MHYHB%chJy4 zO=}!bn{0Q8_9hGFYEC`azzt?)6=S;8encoCCtv7&OoQe$iHr5+m6#5jGQ|7%?7fx= zv$2l^IA|zW?;c~b2V-NKFapy{`s4b~vMb?$gipP&0b3uyEXnV&z;uei+&+L-SF;}g zb)DPNi7xjI^u`TC>PkhpUX`k5>vg&*>EA6ciq}Iod;wd}SMGLu1+bUJ?2^+bv|rqF zb`Jw{oTYQEm2otp2?=PJFefK;sR!N!8ETpU?!!^yXiq&rV9%p)Q|A&U(AT52M9)s^B1@M>JpVBH4yOv=${#X zwfLLVW#_bP)_mM~PJ7LMR#{b;MTTchrM!?l?6SF7hRTgD&%K&(Qe{}>Yr{E;-Xzu& zjGqXuR!=`PuBWAhbxV`5$nIPBoDPck`K&bZngm(S*KAOBi(vE3^Sa29;>K&Fd(StM zyJC9}IO6{D!vzC+s0|KSx?sN@L8YK@ zw%I(Jo?)G4KnYX2-8?v$gjCd4g-K@i8IYI^VM!KvuHxB0wr9lNNKujYKf4-h5n{Z! zttNyi&6Duo3)fGe<~-|4b>TeUpcM3&1XILyxO3t2;XWUIb%|rOwzqfMsux`6s1|@Y zXHqaNUxnFCk+Z4>`=|3*(#qrQ*nS#?zSm$PEtRY0O#Uw?e1hS$`R1zGgD3X@2ACe2 z=}ArCLnUcWj-rGR`DyH$wkJ~WWS(37lCT(?a3$4m$*;P01M;!8NP>eP;b_SU$z63O zXxPbn5>=?#0XMS@CJq}hMGTyA(VhC`2}gcO*O+iwHm4|6I5-soFDyQcs(!5%#9oBt)*;XIDoDe z$n5|PXYto}^!*zcWUYV<$G~?RVdEh00_BA$p-(_4YQQ3_1MK7cv;^D=%`MGU*7~a4 zlTZr-AHKCqmQV5zn7x;~7cC-O>xz6Fs%>RmjYI;OuAvQJXNMQ1_(&$#!e4P1^V}yg zf2<_yI>=^A8S~r@*3FaUZ)2bF%Rc*oEiyfvVV^P_@|uc4uooMWWIc8biqSdH`S|hP z49bFDv)_q)CFHV0#IAWCSTid%u!@5KUwVd5j=fwoWOhuaYLF^9a(xrm^`CW>P%yB5 z>0po+qR|3}dDQ-&jUWm)UbxRx!Q|#h^$!Fz-Y;WFO6GbB<6Q8!Cm7 z>puzkI*Wy~4NpQlzC@5QFz|NiG7MbQGq5=B0HvM^1pY+2xA{^Pq46!*71pBf4rE^k+733Tw@w+vvZ4 zy8{`z7k%r~)O(rkz8${l=CoNcy|iw_?+eYwN`>0rLWr*ftsQv+}5{)Df=Qsz_h@t3h*Q5)ua ztO-u0;8L?6iR@<=o4qzGZiBoATfxeuuf_wnW2JWw_~@mbvipzU$ZW$w=fQ^GPJ6(? zX9izziDgb)PC_~xB7_Skbr=?E**fUKck*ZQzGRwkdGR z6*3FviT>hKj_aWYPZssn7QxQ3n(L$e_OqLPM*D+FM;UjiYjOZE+uXkUZ%LPDA7+03 zj2MmeRM|EojQ77lT8%jt>`HYLc^imETPjMzlQ zp@N<^Mb5GBtj<40*_+vqp}By7o4oXIE09n?CmJ^%dzy1wd^KTH^lQ2D=~a`aN5!%r zzY^3)jvT9B?VF`ELW-jd2$VT2HwqtPzG|n|rf>WcGhx0XUKTc!JhQ!=T5uEb)fj!>FEy7@L zU8&K^x4RxrDGKXaJYqPeV0htq?MYB(_^loHh7DV2ZY;w-Y8q|6Ps0;8izDG_Y$MNW zT0?t&lfh~$-ku+GQWfp>nWZ$lTZSS6k3PkDkCM6xYuu3NgY|`Y`!WyHH#Ee+Vp=%k zHGv>5kj7Q*M2asIf+E|inrZD}seDDRUUm9AAVvgzSm$ba?l$l1UQz^Olv5UWio>)v z*;9=Xh4NTIs(UK|?sZTKslI~|;CHwGEyu6gDBMFWik!P|kSNc}B6j03t!7YlqvK9f z0vY>xC37w?x3fY4rf@btjTP>5UU=w4w-)6?;qb|ZeNKaF#R1YCW= z@Dj=SL=VqQX)?~BV4@QcvE5vkohJtd>hwN{@+`0P$0|)z1YTU5imdrNu;}_pc9>T&1a7Lg7elrJ!9j}Vx4?;=0)E*u&XVXe0 zGYLx}s4(e2KImta4=!Re1*eDXsdvncDPvdLrI1ceF`AppvDcFS8!TgogV%Rps}& zWXUGucC0mr!ZT1T***Dy(PQ)(``7tifd7#S6y?U4zExKWE|8VcULu@YR~*n= z45ZTeqECR@)Wh{;!2FM!PR=c>!}h&>;AiAtpW}kVcYYz{O=;raG!)4@_|e4@TVZp9 zRttfk@-aVqFkEl@X|L~3`$wPyHC(O}9MAbl$HU*A@}CUuk?d&W&H(jgkf6FZ#}d>` z7e6L^=Ct-76FZOKd%8LzB!ppbt{5(xpyyT=xpMri?eve16~(a_F)x)a#)PAx({NTX z{bk!mkPe9r9J`EMw0v4#;!wU+jUy2klJp^JG=`+wkqm-aJ+HnW5$Rxe4B)HVxj8(W zp8jXGB9Tk_Gj*scIUD#VTjpHQuatOu3Os0MeN@7})$g!2*$a>0)U!`u*{Pq7Zrp!c zwgP)dkKJ2sVul5zr;9H`3gp7BSy|kT<(FnWkjL+v1m|kUnt)-QfZHz>=jRyQX zr?RtddJP{|zB@Ynvkz6Wz-*uHZl3&OBu~HBbX-|V5fbT^K!sXZ$iUEold`2W#@EYD zFF#wKP=5-fY6aSJi>FU6(7UEw_14MVsY+hVvg&X=?jzsXhIls2{Z*~nr&UtTg? zn>t&Nsxn#l4yi~C0@3d&`+*-Y-RYfT9lv@g$k-VnPP}3=qxF_?4IzxczMNGvE!xNx zkluWLCVRpfE*6_mKUvdCPPK5or<;2<*1#eP?D*-)a|Moo2+ks*1;f!g&TM$^X?2>`ktF77c-&6lHe~}S~j1CB-_oC3U==uU+>LWmY9G#?oeCh zuB_ODrQr7waa{6*w6Eqb8M(|x?97wXT0!4*Hb&yto06F9B3(vjUH?BGZLKu^@I2$MrJN)94b2@ zX`HjY8~qOkzB0Kpk1*?JuM-c$j?jjhn?0PCXlC6}QoUnoM_Tbi8A2u1P*VLk5{*WD zEsi|!kpQS;4xh&Kw@VIJv!K=@x%{yZP5_(ld0w9!M5qrlDL4e5%WYsOEF{rgxin(b z8#CPiLTycB*de~w5C9nbL@ky1Lcm~|Ax-M6AUR3Y)8X^gWX<^1N5`W~qZZ0dTnQKg55nS^XL;=<1tE`&w%7VHrr*6-N+R6zh4H9J#ZTKdS3tcXfK$TYVRd;#?;TRRe;qCdh1gqgtXh$v*G@<=YRe}! z_T!@`-V_4fv>cez&$h_*zCVWYCbQua<5Q5!K*UMZ>wL08vOjYIDpr0g0E#Im*OU}6 zTJ*KSUbm>&(<0gf5 zm0T%lNQe-x4y!jUa$pA(B`YPy7##Pxjl;Q@-Ya(ADv)QN1P$8zRXe4?Ml&k<2_yUg zQS01Ri$pi|Dn$q+(+D1UGVO%Rg+1%KAjLc0G7=3AjuO1Va#g*EsB^fR4M*fxRcC3o zcr`HQheE>Vxhk(b6FSPjuv6e^oOq(KLKLHCKB>FEVth71zgy2K)*zY@XZH{ zj??>U3I@x!kz+S+715xXnfdX@2`7BLo(cy@= z)?4MDW6niJfPDTx2H0kj0EE(=15%P!Cepw)&k0D~*O)q%WAX-=kgJGdf9=hZpHJ_( zYbEq=Cm{nY-iq>jB{{0zy-wfe-X3~cM*Nv?eQhDt5~s00N2X7*xGOX#LCNPpfVuY_ z;A|=_eeRi`MsXE7mSp*>72wP?#9m#XTVRdM%kQmY$wf79;~s%JmCvAkH+@qErU9Oh z(KgNxMkZl&-ZNz*@0tK7$9#uaf{^CHsccUeMGHamW>%r)5OF9%9*0oa=_JaFQ9N9b zrf)~@G!U?zQqGuMbD7SFP^oA#SOD}+faAD=UtliLOKZJ9#j) zE3Wr}!f>EwrkC(I{8Zw4xAqk&XB|B;FU0}K#c_b+doeXKx~6)OE&MIthVbgCjRF*Q z)8j4oVC301$+nt1$alm?#S8mnptb>PiPBQLAcZ&72tNskaBFcK@~z+{=HEXu>3u;S z?ycA~+EzL5t^Wbn{!&U~t*A{wcx{7Z?K1TSB?+&=VkxeF2G@I4Slr_Y`U~d05CZ*- zM_2y0$4mSBcd{>_avv7OQs0Y<5rIMSf4v)I&UC$biuV3J_^A?{fMno&_tu?n7xLP{)?vlDM=yzlT0O*7q+ci*5+a4 z<7|YqDt+i}#qkL9g81{7dvh!2`Yq%kiE$fr_;TZpeGDUc@1QJaZ+-XF^bBvMXx*C~ z4*G1LFBi_KFF?$g)c5Lsy~yT|&I!u&g3C7h@N?T{icVlHk-_o_aif9BD@_VOkQM(eoKbHw|?%7C;M=2%o)mLPCu2Rb#z>z?-S=E*2z>+ zN{iy#;5F;mwEEq$qV#swwfA>UiT?N-QhlpZ^|^h~U;ST9Y4`w#@XdxqA>D7DWsUy? zxUqP_VZv2>Q% zxDvDARdQ-~iEVDc*1>nqtC5@{cJD!z1Ge<>y;SEf0eP8bpPS`CQ;W2(Odq>QJxdoI zDP8HSk4#ZEp*w_0gaUz&>g@Dx{WUqR3KFE78UP!^(OvOeKM3$Gpd^%d1Wcj)U~Zve z2)D>J75mI2is>6!bjyoY#r0ryOU=xPADxM0V$lJfuiWELyVD@j|2Rn;x% z(7<>^qC;*mQOxnw$LGov^b*1!$LQduvrUTTzD4+U(Ud>Tc&LLd0x0~6F22J+Z_601 z?9KAb{O7UPDYdH>nCY7wy48;3Q_c7feW~v!sP7=|W$<(0UuI8FQ$P?J@N-p|De56e zd^?&MLku6qHCAN?ITTREe!^wb!mJ&~WAiqnAX5i{)=w$9yG#&5z9EhWy_WV=ud1c} zZ58GDs|w9N*jrTg-&NO=hLCbq6R;SDIEqT7xwIKVu<>W8?|Ueijl0BMdOB5Jn#s!| zFcLzuqe#_lVnjdwpxoz2V@^*5?>JejV6l|jxozLFlfPsxPI1IES`4~MI3;qB+< z48;}bfCo?B?maj1z**_8coa&?TRp7Y+I7^wfSF(hn+u_@psRiwT)Shg4kfD3Abz9$ zaQ|tCxI^<{&sRTedb!Y&YMT#4^cCM}$USVc!ibXyenyp82 zxa3nDRQjF%2VFYxkq6+=9(y^^Ox*i-VOWN(6z5nHe?5Dr@L>+?9p3(RG-_)vt$=&s0@-};>*ZLiIY**|~ zQc{l8dg;u656x#vW_!7B31Ra$5Oq^F?ynuvuxUKLEHbSuzmY~5s^u)!&w+(J)VmOcgOoSS#BUDzz5}J*1OU*8z zh@FhQmOQH&%WayTaznd}t%%}zV$p2IU{oFNle0@5SAo0hn)b2r<;D89R+_cyI-X0Z+q-)=+E@boJ_%!I zWqq_=H=nV{3Ny%Q!QVwYOMF)FVbQ|m%sX;XUFWlFYEysLPdw{Ml-Im-X`vZPNDvh+ z^CXQCngb0EZ!gOR4KA4#Xu8|e%*sTonxW6qM_&Kl#xj(KyT`SAf7KC!9;(z8X@O7E z*Iba#Fwy)eznSRvo~`}~1rD;po%%K6L-eL}`ZA;C_MYS^@Bn`m%~Ca{mGb-`7VEet zdO@2?u#B`$TYuBioN=;1sQxle0G8vt1URw!(@}4Edgj_DlRhUEY8&aBH_H>Tp#LER z4hHvf3-Z=*tkE2;Ie$6Nwb&R7--y(K%jkt@}S|!+`Fy)wf0%*vQMx6|E;Ui;h^+XYiW-9D&5Z)C9+mwXbxk zzHgAOQU1`wK#GUcJjo*zc{EPd3QBCtje4_3CwqxaZCuwbOaTIEk>On{0K3p4+s>-wKZV`7u< zY;%5i)wL*cRsA&hYqtF1lRu{4KnV0mE;kswyrPNE_8fWr75G!mPEoWY06}Wo8#=QGd0yJpr-1}lY8jh$rabftjOOn zNU-}0eon)nqA1nQf-`ie{#>f{0M8=91Mu=t;d_d|1a9#e^|!&F*A#dNw2uMT`}`3u z`bj?O_esjR@$|!_^SqZL9-6zRq8@}9-Nayj_BI=qoaQ80>~x>atxSx>M(6l?>n>${ zU`wbmgPrD2fRp565itxJRX)S^Az{{x)B$MfJRauhKo^`7#IlKG|3Gu!Xw881N=F1W zIL9Z}a5{(WDtse4@N!c{=3#F6$@z&;PJko}RjYvXt61$I=^OF08gFAm@DP}%GkzFX z+2#rbP_w1(qj&2~kAiq3t3myRRh&2pV3By$rqG07P0ktTo(u7%# zl>)Am_gIJtN&4M4R7BYMYf|Uz>Yi~}kNDmEb77XY-VwRzyk$^k1TxFTXT6cMp9ncj z>PC0e`HVbe5uT`UIsBDldghTT-}vj49e65>2aP?iBD(!a6ACKA{e3nc;Y@-BuTsEu z-ni}BIdTIPY25TBG`UOdNKTMOKhz1nI?Ql8y589)FjQ<^Rk{BBEm;QcQt)T>8O>i} zG5rsv)T@k;?GB1r5Ne{H_k(u19+8=lrF?J_$m6MvV2PCv&5&J?=L;$D1pLWr`hx+>wsf{i2w#sh z;EmW{fOmH?UxSKg{ZnH4nF~rT&dA*#^rvL%TGzml8v{9f9qHeP>Mh;CDfTaJ^Znv( zC~-3PJ`Rw#vwpw(?R%e3{qI_3!n{&^u(PDADfLyDdgM`iNaL=0%rx!kze5_#P6Bn= zafNmKDe1l|uDUwncW-G4i~H|@2Y2HEXjhD$SDC>5aH9P`_l|uS%!$O~f>qRu9bA_d z#7FCYDX7f+*O0gyCphdphj#t$W~W;2m;NLZ_tzyzjONN1*}u04e+S|s#Q68s|J+@c z5wr^c3Wq}vY-2^^*M|Tj==Pd%Yc!{-rZAg zj$JF$^o$D~*mUfgLV9C%!e zKrbQ^2C?fMSjR#ofspCJHe$BiYg>S2?!SNf{1G}nBVqTf#8BwAFH0#eEG7TGZu-J$!oUBlgGnV#MkVc%LHg1l9I0BiTs`xr zh8_8!SOnV8hDZs!%Cv>uelVIs-Wc}Dv}QWN9+dD|7>v}-tY?sa$ac0_rLHnZdo(Fk zFFYqRE@9u}2J6{=MdNzP`#smMJQ-AO=Wr79&wHr{0OO%?BmENp|12HgMIW#{&Iy;6 z_W#hgW!EorEXU_N?~-2zj%)ri0*FT}T9R)A(u0nx^Spl`Z`W{{Z2=_>#`f@i{ERSt zmGv}rL;Eo3@B)afJ#!hz?Od+xstgs4^k!G!sUwK82dbw3^e$^pxixKEy*R<<1 zdEaM+f?C7;7i(MipeL`(n_h^-G-ptWn}@fcpeNFfO@`}Ihhjrnu%5S6%;sA~opn|p z2s=!EeK6%!H}y&7cTCkAW=5HCRXOCL(ujY3sNMh5ef{LS$-gm6`+Io%tUVu5LUy2~ z!_kRn4z0fbM#poV^uNCA#j(2ILL8FhRkRRH4-M2v5yiYmwYf48G^2*vY9$M z2~L;Y4z@3dvDn|nsYEO%(Q-TEONm-c@mu_%N5=j(ah^>N1FP#C_L(A^?avbyA`xou zeyaR)d#D2-4*xpoQS^`*@XGuK;%?Z;K`fe(Ywfkpz$r{(q^XHzERi8$w%%QsRP&5q zz5JAz%lXlerXcccl)iYbh41p*okpAAa`e@=E_1#>_1le}lkc`?32hf0(wd;tn20Z|tnT1#A$^L%BDXBTt`8-IWTo?J)kk!|UER zxYKRH(Yx>bHw`hf9Q`}vM0mVDgnPVU%t$qVaIQ5QWE#0jF|VEYGC_ulaC$j6#Xq|1 z7x?V+>kM*VJ$Xwx8=9!6jy@73-rXf|+wzA#KocO?`mrhaLm<%QYUt^BU1-2?jD6%* z^&l@{LawWCdgIR?*yTj)JSN{Uk?ud`8Q?rn#AJ}Vo8;doZi{E-K7H?qbh~TbA$hJl z{?7^lP;L&Y$xH1vLVTwiR}O~nExLbf2c|EZV7Ci7 zOrM4Z?LJ!v4ak$Ux~LOU%^ubw7C$I$=xdnztneQj#x#HFF)j@HcWY6H-7npx5#8Uw z(k)dnyZ!PF-Uu`sTEFS%-ncy~A>ocSC72L#-pkhtxI}9@ouc_{v2>x&kL*S6WBF;A zo_>x&9(*y+9d@^!7Njb3{#?Xu*GbNYJUH|zZsF!Owcw;On(R>Wd_@ue1Bq*-+HsM= zA0|+@_*V&rKYWdks=8o)wDj8RW65Opq14dRH={Lh65*JXMmBz1cY4cV_te@W?BmH)NZj)D(FpZT{_ z@RQwIQfbh9)rz6f0x>-;J=I+c+w>={>7*8~0L~Cc5lW2RbWg?2^wafJk;%WS>cRhB zR9Z|J`aDc_dxE-5x@P{5`4LZYcqltcThj%8`z^HOiG;iiaCgGPc#ySB@Y!lZH5H9> zFe zh3pIl5#Q?F-K9*W1~$1(?PF{S$vnS{My*`Fg51&`@Qvo5FGW~%Er#aty-7Wl`D+ID zK1oWKtM@>3E&U>Gz?d!?r`o`HnH=`iKKaMjyI|N6gP$^n?aB}700%X*A&I|>qU#-k zwDtjiX7cRM^tZMyt&3& z<-Tp4Ei*#V_n!;O!tsOJu?LEd*k7t$@xZ}gnP&UKC@vhRhgp%|q9ev%7@2 z_6Gl4nm%Hj%4+$r+F>cDe3t+Cogmh~m$E`I0W>b$_j3Mzcv6+o7N4V9{y(DLI;_dC ze;?jPmxOc(f^>H?kWxfKWDHP3N*V#_7?P6G-O?ZfL>eYtGElma4(ZPK=6Sx)?>+Y4 z4z^?5*Y)Z1bDpFbjd!iX?&!H($K6@@nxg|XTmWjxtc<`%)DSXpV&mmNlqwj}F5y;E zwf6Ol2NER?-&@yHcc%2G}a#wCf0sZ;%8v|H|()#LGWP2O)1LMBv!(`i8dB z&)|{cP0-_ciWkf(CY5(TuR2gH#1!ivSp#{=7j-eejn4?U?pEogkPBC`{iC<7N2drz zf@O|=U#K!t)U;s_*sI^ISal@ehW-CHwNTrH>G+pF)3Ge2#c;EC{vR#RdDVAcJ!C5L z*|Bv+{xq`nyg#&m+G8ml{LTI>dQtaZE)uQD!M4?doti4u?2}j=gin(3~Up+C3j#gg04+e5=sl>8JWe z8Hf%X{J#V0C4V0Wp+VZjXDNK1?iebO7R=Nm1z(MDy0+XBrMY8cg64DttFi4r2SH#f{LGFZn!cZo!Lzyrhz4Ufz1uPOP>X>y#IvRjFaFg3LrNps?^mK&xk5(|)WdG0 z?TEiiTqpYGS&H9=spz+U#{cIKB=C>VD4BHkTEshfHd1crf0hI&6D+4b7L;nHhYQVq z?vYP8J;|bAlfBb5{e5~R%Q9ormyLcfmwcVGQEOKAwqrDKUOFXFbas7ZTloKO7tE$? zH12;~0RH&Lyy5a=cgk1npLB1kM!mj&oSvALOal2B${4y2jG6ffHkF>u$D4^AQgnID zAnXl&IFdYK)=Ag@=Z?N`3IuQrlbd`z=%Nb!qake%c`2>QdhbI%=A+7Y3rrW3HA5IK z8#|718`bdz1B-uq@Q;y5nNN6?pY`exK`Y@sP@F$+~mCyX61#^#Zr#lYUqKIsn}{ z+VSX~uobZ%Y zDbD9*iW6cpl&ln)zM`)O5Mx^8{R>wAiEHI+Q>ffF4r7c`AfywSP_EHCsmb4s3E9en zm%Ou#Ovs>LogPgA(+9FnFQZAnCpWLKF8zfXQ9qdS-`@$JY~Jgk-^AvoucT_Y^h7VL{Easn)*F$>)r?&` z72Zh;qohA8`@HYo@}#;dZnPd+9cW!<;Re~$7Pejo=9&TEbK~`7K*qF5KtS(Y;4b!m zsg9bK34j3L7K#`LRM@qntQI!`Va&^S2{?hyCH9v;s$O5?n9;P5_Ge?~Z~Fi3_h@w{ zWZH>N@X4&zV$t5?wQ2Kp6+P34h>O`C+4^vy8E9qj)Y=_w(p@lD7Cbi zd->9|*cGGy`qoFjBCbZ4J;RdgYr_{Cin$e)?w%Jpt+nGsb4|OCF5ME_Ee0$K4pxvbCB`TgEWutE|v?v!g2bziIZhR?WLuJ2k%;QF(!xE8aK%e6VG zq&L@d!{TQlKX;3-c(b-Nk&`Xpu>Hk zHLf>{pm#su-h}%Yu-Yz=d|O?Yzo;Q$$Ijk^7__&Ddd!$ZlN8Rt4Dl!3O56zce+<~u z@1PC;JDYNHd~;N3tvMPvn$2`?G!X6Ihd?5zLZb=%=-7{G}ot>@2zQvCc< zaMdPqym#45Qus0ggog$7`s&QhG5iWus%}Pl96{L?cG_UtHw7hVNxsd!w!fDB_I%LQ zHvJLHMxCGAhS$8qfqJa$Q#~Hm836lZ`g6pQx>+vC(qNl=f$**2H|kBJ&GNNuK!ers zUU=TdFB$s3?1S1EUwzk*-tDcEF1*cZ{%|pFl_f<))YYNRJ*9fWHs8$YMxkMjd1~%% z^cs17Rut6Q^iBWve6_7Ja@e0!d2-=Uw&OPk?4@kZNcLCp3Ka@^mC&vl5_k0zS& zWIzON5QbSB=DPHUK;|53&{z{v9&R${P zeT0%>d*!u=48KKKVF8XQc!xdc`)&D{Ii$(!+!vxj!y`WvM2J0!F^Vc-eWO< zd-mk`k0upOH{kCLN7i2$9mWaRVg@jByGmjE%h0}%q&rGt=}mjow~Ukb?R->6Gx)YR zkmKR_cwTu+Y$WeOJPV3G%u;!o^`Yhs9UcJ1H7#ako;~dQ z_XJS+hRlx7&sNia59QLlthcNn5dtu+QUJ~)-0bsC_HK(O zsH#f;n{89zbP42}fZCQ|>PS%tt2+~tk5zt|T`zMyf0Nj&hl4>aRVO3%z0w<1k2d3Z z6gF|QMC^|Xp@XS3ErrmvSNfflApzCkkiZ90?_Vo=?El2+xL48!q_V#Wm~}@YuXdY4 zZ_XB=>RB=kmTj^lR!)7a$>d-8FQu+gT|1)>fK%#E`z@{~n!F8@@w%_IEeO|@^ z0nmYC4ZPtB-;6K%_1YhY(vxKYM9!7RB|ZS<{m16hEFi$X^of9v zV{InVXnN~F{p+o;$=LMR;Qt?fieE7$-E7NjtD5#_^WobM2z~GpTj1jHoDqC%hcq23 zF8ysA)}|+AqD!+NZ*0sHK$;oqK2J)SebF-P{-@TzDG$@Ab`SXienucj=10wo0qbCb zwTk>VBO~M-zFlE4?bcl&$kgoQnYQ?s_2Z;H0_tXPzK_R9aChp+sL2>JgAT~zuyu%# zAN%ExdqX@Y2si5cGc%(&F@)3oJBU9Fp`yBxQftqnO{ZC2`CS(a`IqpXbyy1_*B7gW z_3m~VdYkXG>$@Mw$YZH}(<)T^;p*We+p=}DP`TgL%mSrG^Zh2UZBV@RETs^XL&t<7 z9|eS-)?wd)D!~ZOtV)2Dbh;^MXiD(eH~SMF{?tk)gfD70oIgw|+_N1E1|;maKMj8T`ShNAZ_te%7Yf|m2Xjz1;aOCCEEjt5}wx|MN4;Z6Z>0uj6@ib7pi*}KGwHI zbzDS#VWIX1ZclJoWozU4+|7(#%}bB5G9z6jelaPWE^7Yf{q|2Zj2}m}0onKE+>wR~ z`GD-39jhfx|4SV}mze=SSM4*L=4I#VCoF>Sjf~m}BWV$XiA2HHB^jO6honkD#SL0o zDqAV%3wJjQOnFa5MoCpAOx#%OsCh^IMi|>ikB0F6MPx^ZD0*+qyM-c;Py89%%i(#C ztzHv(2f!#$rTne_vUPK`jg@U;$8UXgH`@Gn@m;J9y<3JG_9B+81k-nOL)%B&{B=^A z|G2>FEsLQ-CZgoQhi9pf`ZCJHtzcZtLS@5e=+?A9tCGmi zakLw#aVPJ=^9r1(sCOaf0K4R)){_dyVBpF83D${X35xc*yEzLD&AXm){qwS8E#2{v z)mg_ees#x%>)vr*s87oXTWDrv3^i4ygRA%=ar{h z_(OL#ZCm-&>bPND=5*4m%R}k?A9m3P?K6&TEgVJb-ey^{-fjnP%*@3vuBXs`Zs4aM zyN@{NM8z^WEc8w9aYcH^x%@-QE_3GbFT?keI*<7GfKmpulDF;fW7E=fV9Jnk;ewb* zIo3tJn$&7ALtAMiKM@_3DckMfb9c4ScHMgY0w~{6o%tvrUj`5rJ9^z=YU+CN|G<|S$s1J#ZtjnV_8LE*2rk?Kv4SI+WhyFaX*qjq{u1`w@eg4{dopojp928RcMbWb_4%F? zP4g7DfJ|wKd-F2G5>Oh`?fx_K^PNf53o2>5w%+h~(>Wu*G37ddT-l5M#B7DM2^uw` z0F@O?7*kPZ-_#7Btq){-Iyrf(zF)AJ5m-G@4d&#ZOt0hVZMUKNN^)`NWPn{BqFCW$ z|KAq#2Iug2lZblTVWhz3h%}CLL;!0<-IO?w=V`!_=EN-Z{BfapEj@C}xLLq;!9!uA zVX4k^u>`A@UDVTNW77O;xb~*i^monKXN~@D`3Q|}2=cGp)s|ixZ339Q8LRoP59k`n zV6J|V$%2`JLEi|_W`)~sS|SSWuG;Rn)t-D9hBUvTOcaDzE8SFEfuLy5As756%te>2 zlA@4xxw8h--Pr~##LsJg}Tn(2&YO;sD zEM2$HyX9m>!Ec4F1_@(+d6#e8)8q4IsCM@S^oVf>dacFY=kKV_JX9FCY`#YkJKW0<9md9xb4UwV^Q3RccK-o%9d)TyZ05o#pYVX`fjjR@IyZ?sDxg9d!|wwO>+UmZ ziiwz2YWAnbuU<&TKK_?y@rryE{_B_5XIcxSbbTXijeObAroQ&jhbb+=jA?ai5|`J6 zbG-*}{4iYXkpdR$`y{F(Sn$QZ?4F;d1Q0k?es`tJ{c+niZckv$AeCCb5j~};u`B#V z7!mDfMLp?<^C(`>dnj#uq#wK7Yqy16=H%jz@Wx&50VI0L1yTBqs_lvTyi4V9ZP!9x zv1+4=A{eGAcVSMN`_HFr$^4)5a0vlA;KwFgddSBKLw78cVN#yKCjI9!wTC}7FQ_D; z+IfTrhpLRe-@`^7$(gHM-=gjxor#<6n;fZkf-1T(CZA63?>k(20)zLBF168p0zH!=ZO`TuTHSfZo!q<6S?3rxp{YA zARdeo2cy9i$xDE!2IjGh{4E#VvgPF zKD!T{%KA@508g7XvVfK;o zXqLzFX`#uE%`rTTieyTs6|D|wW^pXk$h?ev5g>jd)R6Vzpqh9xaVOU6=HSJcw0D_N z_KBQ0z(u!{D~bV+9osFl67CC=x;xwJ!IBpQ)4 z9rv-BX8v-#?i&i8ZYo5Br1d(grA3li zdlyUHd@Q@aO^JB$6W%u@S=ar^PZtvX!&#y%#Lad7Aze16y|V>QP{|>u)M3Nek&*pq z`1f2;8J%OjpwGn$Kb3}{MOd0OIoFUN=^i05K2MKPs(;w7+p`@mZJ5i{SBV|-BxjB2 z@8sOKrG=tJsAmcXmhX3HuM(DytCL&G3zyc@%Dy$0a(qT{JlbZWy!p?>L_h}LRnGMV ze7k>-(j-xu%JHTh>5&XK@}SYg;rmXH47YkK=czJEMad^EZTreqerR6Q)voHr#Y4J9Kba_M(p{%}$Q+a$2#$T#>s zK_vQBC`hK*0?TJSC_~Y5LBa3@cYyj_wds$uVTL^OOyc( z64IZFZd$n6y&nKnGNo(W9`>0kjM9KiOK2i$SXzOIPpNO5kxHNSK6TpB?6xc^n1i{^`Cj zKJg*1RMCDqy7b@HYV(lAgh-Y^nZ5fG4x*7zH`Xhl@VG+~U2^?KNVA*f64xUYz?qsx z^w*c@k`14j=j2d>g-c>v#<>_wKe<87&YzC(1<2dI5y4!<4bjehHcqaEbu$>tOPIn@ zcb|85$z&?9lIBeBFj(2o0iA!WxsmMXCE@SiaNq}1zmsRzFWzTYw;SJGPUtcIIbLw( zX(YZfsyTIi2r51duLW^?r#yPsHWVEoy2>WJuakZKx_)yl9iQo$R+OEGMcTlxtEI>v zM`&oFhy}s5Y>&unxjet3ZdV>_JG4L?8{ie z7T>tXN}lANDL=ecGX8$|O1RRMh@@Z2x?8j8D|#wsUi|7Ivgn{sP3va|o6W$z;kp5a zi^=>wO*l^5P2RPaRb`InInV$RDKH>{&v|WE1i$)u5&3~lu~AB>Jzana!J$iiyfqDS z62j!Acr=pS%Chb?}8N~twjvgEJ4#}&02@at*ukbt@Dw%N_v#&2V# z0=tP|IIq-QGxFTOO-kZsJ`0YGI&Z+$P+*H>(!G<)39Bm-)_c23uPIhVBqnp|&4aB!7p&SoE zFWOjKZEE_{Ism(W?|iocv_TTlY9WraX52jqSNF60V$FabyUM2Bs|$EOnf6vL$!lYPv~!lNIOnhgb~=2|85&7T!=W zpSf5!Cg~Axu9*$M{Um|e0uq*4sQH!Mvex{ZDNSDP`1de|M|Y)lW+bDQ3H>8Gh8LGbL>TjJxasYRue>efianRDtNj7 z@I7FnIgd&3qoVYMp^MocE(ycTcS@f zk{4YKDr=?578);~Ffeid>zQcT{iyueaxaF^%e9BUIp+M6J5DAjt6PNB5a=B@l|H0I z{_Vvmd}jTcR_k;9(hov7(aS0r1}uRkG7;3nu?>bP#<>1!epdj2X{Xc~ABPI8p0yD% z95F@^2#|P*`@rCbhk79a#e^Zx<0l4{X_obM9BzOrVmNv*8?QyG=lh{uw6xr)tt$_&H;d8~<5}Aoq&IvIThb z@5rD)X*IUKdf4R%(D*0bi~9LC`NCfYP^F_(C{-pFBq2$c z%9FB9O8F;3%{ws|NqDWM(*3j58r7firO!)>I!3Jzd`8aQDUd>zi?nnZk1XU$z?vgP zsfa<>nCzYBwx!0lPiFYAmNtn0#kI`ym{{UXZAyl)pK`)7f;0YNp% zb^A2v%&goGfaWifSJYN1`_aqi@-8)2n4w%#^#IC*YOZG4!ik1kzJUOb(3wE&FiE>fY$WdBNp&UPFSB z8VT5DST&2q%(zN$P+*#5huRYcm8Gtq)DnvVx`PiNmeKm9fR*4sVKv^9r&7cO%uzJfYkR9yk5Pi54dPz&hueGpIVM{h zou1-xDjPWs92$g)NRRW}&<-N;d989Bwx_vFy%Ib+isH)!uGG@GyrrGj5`Nob*Y?;* zAt1?MzPel3bAJ~q3%w1)J8e~lS{bRa6K3$M@1<2UEJ3_p zE^JpbF28h?I<-izPRo34D~9z6C_K*{s`~xIzTQwObTze8;8UutJ=BQ^LONl&LSu!sE3q-7fzo zdw43b@QZ(R9E2MKX>h#l(h>KkjRHF>+ID7yMv7SG$)Lx;%+sDrx=3fSC`Wz_*u1uj z-a=Y4KA zz8sNY%$y75$Nl2OC>KxLHM^cbt?hM^R?NGgb11>va+IJ{t@t|QnJ;#%Rq|CS1m^Lh zSHLAV<1Y;kgR2(l)T*$)RX;q8ev}F$j%O@pgF}TN7}i%>O{T^viXC3-N24j%k$=#S zK`et@Tz)QCA+C9y5sNGEFtLFD#%U*w(KDZTxeuW$%e96U#_A4**hf%>Y))hX;4LAz zL!njRlwi{quc^slc;hCJ9ap5C06D&^fE%_iTt(aYDOj(U**RDzyctZ*>M3u<5|7Cf zNdc5y^r{&I)ghZz=A^1vo~-eBm5vl9UEMhgMc8n%#6fEnm`h#`fnQ}VLFj`b>IR_3 z>vFg@kHl^h_yp5skd+nw&jdMznmnhBSiGb3D#^WTr?iIv;%avtK73G~&yFbwBzjd3 zF(vnNISRTpo<%XLhVWQ<(IE5w#Isgc_oB!n*W~mm)J7#E3{7%W#G*w_KB^uM+zhl` zzmPZ-RaJ3u9VTAa_YvKpIlAb+^J6J*`?;{T?9DnLF#;y#jF14s?065G)iH$9DwMiw zwEGvT%*LwYRZ zEe-yXH8L@yhiI%GEJf(jDxRz_g)$nQYhlT=wtVD3l}?AYZ3b6kRs`>qeV)AUe(Ex~ zpu)Ax62hEtL`>VMg@!>UAdXzbIKwzwa`npkq z0xD_6WD{W?TKPbOasUk}O)0{Cx!E}M;(7O*i$+VobtJhyigVy6ZtuQ0Ev-00vlHB% z67=tn;N?<=ai&iB=i|3S#@p{-a)hTG(arCNtWG5N2$Gv%iL?(&ib|MEN-Dn=82*PR zpPigB{LH#op7-f1x5ST1wusbX(3v$#Z7>D=%@Z5zDPyEQQyv+6i4p-;1TzdvA|id+ z|CCdq6PHpU&rw^X1Y83&^L75tUmhLG+E-LShhN_1k!+o0Us|uR<48Ydt4Llph+6v& zTfO>6r7e3ZtCfNwF_JJ0l~OqjXA&wp>%8%9|ID7?=s36cfj$#@^C87~ic!(fk%x-P z@NT7KY17OM|8!?#IA%}D!Avy(s4A4%sIdP^ovHTd8M@ZjVzez^4R67)%lBQiL_~$3UA*%OnV)Ff!}vK7^-1U!6vOsP0DFl3nqb`V*o0 z`V+wJHUs(4xoYb}_vlBHzpoY%rV!YTrV)+T-t{$;!n70sEz4J|Uv^ByhFPfrtsaP7 zZz8N?G1)+GqyQ2arX<-tCY%jf{nAk` zL8gQKk{1;o+o6wUT5?@3$B_qRQ5!@mM5|)MA-v66)yAWVC)J*mC<4^uJo?F8nuk`b z7|z8>AnwRN^a%cp_ga<@6!^c}J#qO9GG~IgBd|ftXD%BptGtd;3oT^=MrG&(aM$zD z&!BSwXuhg$A>hJi$WKZ#j(z@2VKJzC5bTJ_f0vH@3^}%-MYyo!oN-p*f@D<%7#UX0rc3q9%iF7iLeEHhM|oKg zwk+@{Jmh9mGTr`%^$3)u<&l8CuVqVHi*!&_FUc}&w}LRs)BU%%Wu-UNJl(!^YHjHN zD~4(>>#PGF+3;-a5kHEHOE0rcQe3@3=9BK+3qY+bRTsc7QMO&~y`w?F_v)E9wnv!! z=Vh>f8SmKje)n;|kO?a@9pZ0GPrM|k?@`YfJ8lAlZA1& z7`;FwdbdQFldrzPPI#qqUhW>}yyuIF8T;})sJBH$CV&O)t!4y@-RB6wk;61UdRWoBm{7k>nvC!LpG0jo2t<}el&P@shmG&(n>>gwwm+(*yUhWeZ!Tb>7*6HVmhGS-a2em&Ci7TuS0Y`px% zo?D&|OL3$oYQkz$YFUKexmdfSt`DL;VTXU3e%t$4sKOvFQ)d7wmj5A9IR019c>y;TNL|CCj}y!E(+W5DJ{Cge}|`4eaE zm6-QOVtl{v^MgrjM5{x6-Vw(I;~|}8v-KKkA;@>}hZAEMUlH2fJ^W#(pD3r2R~{n+ z|2;Rl!g=<%a;p6o1U4Sn7Ux??{xxZc^_^z*8JUT)Sj~}nP~sU*D%yS1&+oU&uUcJH z|A{MIK)l_D!v$|NdoS$e<+FUhfdy6&Ap%u7Q4F-^!AZmO*R)uc5brXCZiP^itLz?) zLBh=gGcf!BOc1x~zRF%K)~f3_BV=Mh{F|a>;S}Pp=#{)_bRBVuID^|_!ae;lIS+y- z2`-=e9&r8v{!aI8;eF?CP%zK5Ky+VD$U>2i8&Ra?&VdQ`yRUuMm8C8uhi3f2L z_CP-kq|gLs1a%W>VbotR9L06p@I@LiC1236|735OJReTLfVnE`djNri)I*3!g7Mb^ zeLz^y6BQ0MjDOU90QRqk*=)9uqfL z_HbZHvU+$&5jl;ta1(?kAm#;0Jd$C>>RDW=D2p^*O1~T1)l?TEh&&MY$=iLckx17a{VcG zaMCw~49=0UfeZL#ATe(L*HKcgjt$yD$|E$kX`!tLW;96&%2DMcbHO-*wELF$Y_~yH z%VwNuc#Np@eT^GBjL>&1C^Z4dFcAmGOhPj#Trg~(?po{n6MYU`r4BP+&D+Kz?J@}L zs@W-ff64#SDfS@!QV0rSu@J|AMHQ#E4UoTQz>-=+6WP8!tYB`k$!TGcX+R6+x(C|Z z$O&M;MEitt<;+!wV{Jt#=p=Zsn)^~1VpPrwD&y^UD*oG`V8ePq{lH|biOF^0u(dd) zbZ3MTEiJ?Lz0&>J!Ie^uMj4pm#~%pbPd2E zWFFuhIf6B1k~JRB^g~arYF;wheQY)ZhP}7XjxC9gMA_QRKFWapBoHon{W$i(fMxZB za?|dwAnz)n=!hdXaCjw@EQSHB=7CCLh%DXq)VJw=7-fum(@TrDl{Wb zP{`yY(xQ)1L?J*Bqg;TtBnE^VxyQnI@l4`8C$M9M(?6n~saOf~T_Gk5OPByB222j4 zF)U#sX2X_$8W)fwLZl3Upubg*f3AI6NQv3&3K@%h1tAu><0E6@7Nv@(ME<oIi|1j|a zdJgw___BIHua%|I^~td4UF+YUb9DA}Szb%hdABXgCY+&0pD-@H$k zgLjvnu-M-`ydS`LbVM!bsy3JI)8LtZ6Z_e4&9O?z-70g->(;F$hPa)4wu}X@it1xc z6N_9!B@?1U z_Qn6j7pA7#Ek-j#!v!F^ls#dZ8NIn&5`KXPS^ZG30JI(p_HI^$$({NrR|i(mF9Xt7 zx3_L~jl2mfCVQg{glS;Ab$kE(nKra&*1s9+BPfs>obukE(aaZIQO_}+LTZ7SqKT^e z#duxe=*Ml>+Lg2CvcIK;@B^&_33e-qpTR`(4JH}+j>~NjA)Iw&xUmo+nS zrZJFofnoN9L`=cI2+K<#uoN2eH_-m-&l{)Juj!mP0(djLFfE;T8r*om+|in5kn7iA zc67UzGPQFYWJ2PR;6zYA1`+5fH8oS&Y{DSMr3o#jpRP$lrTy9&CY)368FJJR$2aq2 zxu!k2^}66wfQQ&(zRfupQ+bY?0*fag11NXs@R4d0V^^GpL(~>n`kEqqbt4ei2Gj4A zYl0y_JB}%(`n!x(jz80zXm9d}HH@zG@u17%+2px@ zua;a z((R2>(Y5QTeNJQ{1UCP&IqY59{Qy@AW2TS1x$bh8%?N0Zo=meC(?$FG&;A)mvtt7# zmBU#dnAzFuh^$nL>kn`X z*NoK>6h_zy5Q%!#z9nZ26FfMd75sp7p90TqrG{Hvj6n{smt!XFy2ehnRq~eil!uW? z0RQDhQolm5Jc+{X>8I`OT1!xwD?f?)lp;81wW?rm`({Pc;s|5rwNA-8i;I5^f}*V! z;d@<7nPSK=a1X1anuZ0QmEBBS%}keZm{qjI=w{lr-58NSNC@nn3027HR($V&plMy#P1JJ~_c>(DfqW$!L>cI|%v zLP7jY;jH7wNAb>|_u^#}Jk?53cwnk?jeM^O>poy|m67NXU2|?Ot<8L(DWfw=jVL9A zr%EH~H6=E30>f=3;{r6zSx}#u3FKK^`h>naMKjH7KKKfHnEI34MWw{kXYDIGNFSZ1 zYhI8F%~BH+hEWH-H&s`Z3@$|^R^<>Y;-?gA)DLo@m1e316W%BKTLNIC?_VG{EvA>c z3&in^jRhXh&O!`Bm7CfgA8RtmVEh=52X^}+xbp8JjXo0(Y*lV`8@VsTLeH$-o(pm zyD_ZqK09N48XxdvSIj(Sq{rEo2rCV{{4zNt%foLBrQ=SHu{pWFI9$~y zNIK#`|7D(AFJJ2ov?GxKBqXW79#KpVn zw+U97a#aVI<%2!agb5!WBve&NPQLm?7nM5Kk)(b)03>qV#htvy@r&es0;lALnj}w5 z!rO#1O@jdqkbQ=N!#LuwYwRaq7&eTs%U_eeOFyhRi256PtzuUW>@8R11d39mMEQF? z%0%$uny3L!82{^v_>ul$;zhPJuvqvOGA(GVDyg%r@gb( z@EZIgkgx!e2iI>yTK1%^RU@bb#rsc{3qw$t$E$$6sJAc_~{ z|5(MU78zSdp4bAzKhKzQ=)Opzf)ep5wCSx|@*Z6#)h~CHrfsH0v-Dcm)qA4mnbYWt zB0_H4KGnsWABCXjx1_Bec=$e-<3QD$TaizV8Hj7M`T}WHfkT^lVG>?2X)m)QJu#EcnB>Z)ViOD14SBo{mUreFPg_JuH94^of>l ztxZsGXBC#Yy;<08dhE2f)ghwVm2lY$IdXSEx>^{NEt;Puk&6g0r*^B`x7Gmexj!H|mH)D}N!CC=%LQ+RKY9t#=UQ*R2~fc8O&@C>j>h@5sLx8k4$bZSo zgNpUDX-KwIP5&FL7|?I!%%zbmWqm3cVA4P(cl$YpN{0JD{CYn3G_BlF-+Tp+mC9aH z^59wXBmXh}f_iNlT;I=~$-!#7xQn1u?HsJ24T8HTXEa94lVpr;j{iwaD~jZMnj1{J zfkh#Ph)2X23~xk?)7XRY{Xy)iYJZ&Tfb|~Nej@*hkz%JkTOK2aRSFs*WZOqWaxw<~{R>`hq|mWi^}^Gp29 zuNk=t?jNGXy#2qsUBQP<@=k#Y3fQOnHtdb2>OjS*D!$JqmlBuc`tC?#KY};fje-2T zzgA3X(p|VjN7hW>^rhkF2f$LlBtt{dQHpa-xG9UBmNonQ1v!!v)hw8Oc!)L3%FD<% zI`(#0Zt4|B2&218aitkYYr!=GU(c+nC1N`Yw+Eo;s3%S_bFYWU{y&<|f-S24`}#9< zNq0!MbThy$Z^t3Oiqrj&!GAXhM()Jha{wk}d$K0NY>n|X~; zq=w5KMG&w7&7C)NSFIgzu(;+u1q5DN<|NlB0l*{Fw{)lFFoNyTdhgpGmst}?h=M=5 zEi131qZwe@76PwLKesFh1#}MB$Ip!zBj(!qRJlb4{0L>D5IAIU`Lxc`Qf?xNa9tIx z<6u^$*FDm>NB5K9r$y`lW*H(1@TF2K*^?evxo{ZZ6|fn)3cW(mfP3-7{*qIfER4aP z88%~oXm8p#s&%t^@%u0N4}S|>4?!iCxk883O=@Z)4x!H=Te#S+Q~Ry` zb46xcjgUt_svchemf8e+`ZH+Wk9dOK=eknsqQK*NO+1@c_{H z`hVjWft*me9AHLO7IW>uBVq__S$pSQg zg^=5Ee!X6BnH^!BH|4^6#3{tAzJ+tKZFP z;4Boy+>*{;S;MrCcv3`h6Fr*akiQ?gcSW#HEt}N&tXKc;QzC;HJK1m5?#|sJh*s5d zlhKFg&N8~O#L0uXq;OuC4TgzDnS2X8Vr%OI119md$pKhd;jjLDsY$T~Q9WhUe|A?I z)~$Pqea^j#U4goFMm?4wa3AT1o&k{Wn#XT3=D0RbWFES3jls-JHEjINjInH3 zc#35_kFO(Rz>DYl!rs^@ViNO1yclxZ=oq6R1lY zZ)z?RuzL0xlQ^QPX|;TzVwIz(5_1Ro7d}o`s$?XDURC5Wb_(23uWyzo-Rw^#~SAIL}U|#*? zhe8#zYMQ$`dx`BY3FmWx)g%?z{W83Hj#S)bW{?sy2}IOTXHGqcppPlYEv=()!w}Kv zV%^IC7R21p<+s@$I1yg|n%8uz026Ix(yyu@=s6GU?^^cUCYyiJhr8v?SO&s3n7E5t z=~(+lNag038UKvNh#=N;%VbE}gvpE~CT)P?3rW$^r{UrskLZ;iS3f=?V&#`snub(s zy6{N5aPe&(p09pjA{4a56qM*%g71{Qq~sy-XS`lhzk+(&4Ul#95R*ja`i+5K3q|OA z;8^htB+BuSWj>KuzL9xMgRs9qnzX@AoP^)BYBR_j8lR<=

p|MPO{8-&^ zA(M_{iL>j?`=@Pn*})V{lv2{78j_C7si~WD8<0BLUAuyEjlfgqznhW@OU$aXR7aLi z^xD%Kq50cynI|&>`xlqqttF2sGT`02AHh}2kzIk_b9&Z98R`glr&`gh{j+Vljv!>z z54?4D5?$g_H>D!?`)%ijqw^()0XyC={J|8ZJHC=9C)8iu{h2y6*Y;QcmfV`$iAY{_ zbS9f6RNk|n*pa!@sK(%pA;-LIMD1>Uf3oeAie5J*0l*$fVp^( zp!!P>seJ>9&KepgEplBSjYpX_oD_TQAYzf42KZGFjF#MB%^8bLw@JY!x{_YWZZPZv z?L2N2O7$ zGS{t)tx!UxgD^0V&lhkmIk7+4TdE#0PYCjFej!G7;<__5jSn<&%>IR-2-Lm?Qp`$z zT%kiI{sd&75YxE2%+B#%Wy}tKc8-zwo5L$FiGPCm6mc}X+MF=0*3f44%qv+bl0VW} zdwso;xd>~4D=D1{!Ibx}A6*%={xFt~VHhnv`=jL$R1p3%QQ6i&E9j>OQ;hQ*V*jj- zJd>3Y9OZOwH%*SbR`1v?qV_Md^Pg>%gw~83=gl?DUH#xX)UHW#e9XAzAj6EUoQ>Xv z->j=TT738VBy5joc~1MzXp=F0-1r(;6I8nHsg-ru*7*LMj8EB@Ngf26Owk^!?hTSC zStW(AE>*4u&Q8ww>Fn!uOsvK2nH>7%oiXdi*uCtxRR0Lt<%{D~QYc_fcCYaN&u8y4 z24IgSVv@H_I|{G;n6%GN6oJF2=4VO-F?SdOhrr3=q-ylG@I^!>GDIx#T%RkKB;{P$ zw?tQ@5932BDcg36SovY9O9--+F&_kDGo6o!4m;tl&A20xDzG>mR{! z3mjM?8rZR^ZkNS}WR9_1(|}lY+qqa86e4=G-aL5j*mKn@i#fwsYOucj)6A8{RXrZu zoQ5TzgnONJLw{6%a6y(Go?Vv51FS4WERqQQq!n~kkEShwM@^CzgZ6>LcUtA`noMLk zcObWHk!bv*w>>L_=^uNkp14we0$P&}?@y0e z?h4;Msv>X%qT{PP)t}$|^PxPlOkJ(VnGNI>(bI@}pcMA~jS5^Nx}U2#ie*V)L)5mk z!+P;%1OXL72ltMCB(cKX8=%sP84IXFUoFvI$jDt8z6OVY4szusM;p;lH}R&TwZha2 zkY#`#tnOPv=rWX)2jk}jzn;Xa8CEkJb-fgtNj*pQ5iX@mgKhsT$U@{@P}^dAz}7_w zFPb@|zWor_SxPaz5*I~PDDsfRk)QnvGMF*UivfQ916Uv~`vPFU;KRPDY*f z*C@|SIz~l7h)q^y@q-Ej$iZLyRLM*QBOMSdP(MOK;7J!9y&MiCUIY&l`T*%=`t)(~ zJ4)d&&g0#^IT#Q4TO&m|@QnyF^vnTEjjt)~3QvGbF{LU-4WMF*nU&yZZvqToxN$Hr zH7fc_nKY-gh7hnI0ybDr**(mAvZ*d?xFg%s+ZoVye}cmwKGTVH*H_|XU8dkU{7ztU znSMNbBh420nZ99hp%?reNw=2kNL4!X28{*os1iP^6wt3_4bcYZ*zZ3a$Y0wI?6~2hHCKixly^ej2 zl~)iZpBf;)t_!AxD}e)Eez8^{7gI16Ys7)epy@bV{6p^sRpJj_0YoAS6qWpCOyH0W z;k1IgYh-ZzhG_y`(_Hu3h+b+BHA?c6b(b4y{*hxy6syrpWmP^KFx4aX-ufBBeMJ!uhBQVGibAaRb#r!ReRA zB!Kq7uaLaZrPQRP7Nl6BQm8wCC8X7#^SP!LYze3kg!gqOS}h*_ zgxtH(ff;zIw^NfXy`%Vy0HTh3k4F5?L@>KHUqlSe^w+Zn3goq^W!%Dcl+z>O+_Iz- zlQbNKmCH!O8WP@J3%6^$hw*Nbv}cen>MGcG@1wnW=;sQmrPLLWdI&Kp&rW^VPeeEU zlOSM#z_MwCeSyy~xxji`=2QWLMI5FW4Roa?eR0-imn{eIhVL)B&bXDGFx z`%(U;rt;+e;l2;Kt1Ukf-RIN?iN5fMbOgwtrm`jB#vLdMvlB%%*>(wR*pyh^5KnlY zR>h%cN!I{}3V9I?VnGTQc#TqXAbT{Asmu>?D1Tw!cMRj69Fg{(Yt!uyocX095p{>#@oH%dB701Olxm&5u;BYol7&q#1cWP^U7P8u0MHmn z`wBUR*O&8O_enQUY*yw%$l3Zv0_55NE@*&(++?Og{ z7{5x$HS=q1dy~t?skwQr<+{^%C-KlvVz+Qt;}Zol(Dy+;>-E$Y^o2Ba#UHe`2+>zsg|=;fw>dHfhp!#c-S&758SmR@iS z$!xEHNNnLs5BL+T>(2t~k4nRl{KBjh&%L)VX+{jU ziEyW>XVK&8FwCbWMeopXCa($?+mV*}O8>w(9)>9IFu;-n-D(87d$#}@E4aIcOdCAT zAKb(VnCoo*22%Zjq2+iT0lR@u4vEzEttDL0FqBDl^mf6LcL`}p|KZPqopRmMB;~@Y z%;bBS2+Qt~c9_Dt33Q^-`J;F3{QR#uLes{r)hYLRC0Y-huimib``f9`Ib(u&!+#yb zN*$%3kediY)sw|3?;ZC4$FGuC`^V7D_8MOnu>z+>Y9z9r(RHv^HP%(8IszPcexiMa zYdd@qxC-Kyy1$nH^};}wj@u;YML!^YJ4Zyqmo9MNk7b10!|5L6K1KaySNO%QnlFdI zXiP{t^*J;ROZ>*Ju#cswn`}B23tPjSZck4AQw?>y{p|}5DkO9P5r-^o2Q*G9H+=g> zD(B2$jL>#K#ST6R?9nKdQSiAdvqwhO3AP8)^KbbQ?SR??11?sEL+@3t`erU>CgCmw zQ&!n5AkfFW3=kPmf)YgZVaA*7(@?hsm{*Y$XIo-jv;kCE@XEr8{}74>NtWr!zX{UC zk!B0i?lk1t2-ORPYH2d%>n|qSh@o5N?J(-Fm?CA*#-6^i}@yq@AqmNJ?1Z^MX$Mn??&nR z*aE)>%^_BT0t0p%PQsU*x~c#SBcf7d%%joqlgCMV$#ftCpzd@qNn7+NdIKZ8!Xz@iR5uwCGcVn)FXz(`~U$^ZJ>_ROr^AA{@Ii>Y(+5UI3h? zsSSot*-D-+WB2Q0K26#9VXn~=tQO#Hza-|GFt2l#J6AW35rWVuRq7v9q>)~MV2FIf<`~sX`-VC z!W&T-WswyTvG}Ye@umk^0NPQI^8_Hh+h|C0BY@hXaJj;r<1fxDIrCu`wr@BdD>jR8 zpV%Z`532NgyrhF)F=Zc@a?CISJYMx@*Ru3ZnbrQ7MXapEv``;+v6t6n@3)Stq+*V= z(Fmn<*&>TgDlg=$`=Eq25bcUrc8wU;ALP;nM-Xg_MpP(D_|KBFRP>%Umo#RN$5y)s z`Kk$TFO?DkitvrG@QbM}r!}IdhE}ZtzT9qtZMLMjh_ z3->slSqhxefJA^I~w~^ZgC{eeCAx-`>FP^=M}C;Ej{Y z@H&<%b@AwM@A@0%$CPD7YhBm-&3T_%EUft3yiGLz~qtbk9TjfVvOiEb948M z>yqD;q3RCXxaGaaC;M1#3E)5qyi@9ex1gCz0abNG&KgGxdWLJ=eKenpb+>n z%8x{Y-inAlO>GGOf~H!)NRUu%aNMLHxxWO|HW$8wP*$b*S8`Fv20J)hprEi8p^nKK zTxf=3gKcsm5h}+*I54ox=2Npe!cp!~#z-S2qP4;8NV@2AA4@cSB@Rs!Y7eJA8~uTw z1ou^HOmUXJ@ghJ+$}0e4p!kBwNq%hY^~7iRI-&qhf|c$b-M3FkO_^ld&aw08G=CDBum#wW`(dY5zCgi>P6DH0`_73TJ0S%Jgj;&b2K1((?D*o@=9c?C`&Ov+UCM z>fJSQBuXaFpUWe$-WA%qZmLKc_VK}mG6ZeP$vNpG5C73X%C_q-Xkvh`b8k$M$AI9d zZhq|T`bywtzV-#B1vl!$>=PyGb>mZnr7DT3{sc0R2^t+1}lN zMSJe6FS05$tc#s@s?JWYA<-VTZJXEJce~b~Cmv2%vCT*vS30h|3LesGun~EdxFsRP zE$7zkA^)mE{XEDXb@Qytv3@6Y-W&dF55^naee6TnZVG?uy~C0=a=OEwHe<~^z93vn zx*OlEcHFy3K@3HxPoCfnf~3AfgP5r3Q{n;|&}<`0R&q&MC+>25ZpQNjmj*L5RxFk zNUKGf_Xk-whEEb3J!JNyAk86?yvvA=fkbz-VZkN^;zO7k_Bu_?ke2(JPti~7C6pd4 znF#c)Q%W+a(06+i9zIZY2ePBNu4VG)yYOJnP@^H!y4KP*W^vLmqXVG!u`DLk9%g7C zTC(>y;LZ6`<~>%rEGmsu6}{2-f7e5$L2qi@F~1pg0LVdJ#ug^wf$L)ay1qHn2+8I5 z=LmQUx0{+WBQjjZUMELoHyX~K`juVD+(8S!*r~tN?Sc*6EtG~*8Tl!@F^u*M_V)5x z3Ugec{$Tn;F5ScxE7%@AAEs&A-jUb^9u4bI9TAvF)R%cr4=e*FS|WCYncOFf0A?#O z+ve`!-v5GnZzA>6>cyG>TARhO41;ZfY?iLgf2%^rO%CP#u95!xmcWj{x4XpDxhyj# zX0JEV;ouNMw=>3fw(6z_<$s2xpfNBGfJ#dovuXeFuhJpgS*L}V*(Ond_Fj|@MmX{J zWXhA-maVRTM2G_|nhv4R4o!f^Q^kc)UvYOjqBfy?G&_ChMV#iap1fH~2EBQ79cH+> zbW}XSI3Um z?iW@d(k!>=#@Kl~6D3xwA=eLue2CqbO zNw(FgKJs50cg43|6m$j@XVYc*Hm4(>-ugBVawA44kEmTx4MqhPI9P~+gyZ<08k`0k z40lRjx5FaNvwt`4m-685#q!AGMpp7b=Xb*9un19*>5H<96YohU)Efp*E0HtZzv>iu zPj!3RUHdBl;s!QFH`igOPWe^Ayy~PnvQ3I9O%&BQDU(m{_WXoJb1l9XmK`#-9TE9q zi;2s9 zY-Vud&310fO?Zl=yJk|y9UL0u&_a*UjYKoJ2^i@%o$g;9|04`ILykyg+Wd+|U^Kcf zi}F_;3!%%MKbfqX{)F8k5;3-Du;Z42x%i8f8e2fQ#wJalMcSUm`Jhi+)Uqm>(LhfN zopM%yRuhS{I)FIn4d9osWiCQ{&NkS4Jt_Os&ZLsoryC z;2x2gnn$NS{ZrG_3%vHV`_E=)YLWfm)anw{3}8ix(x;oZZeiH5H#5xM3D#kvm`P5m zg40|gwJFb0H+8{7NTSq|*uuEnpGHy4g!ZXV=A>HS^eo+cvE&#~+HMjsw{-g3?lLd) zSe8@`q-O|&ck&4o&6AWh98?r$>^?5=&hpjGOcs=A;OO;)STSsfxYfIHSXl;flU3+E z)9_3p@SEz}Nh0pIJH9B^QaP&&P>W$=9&dO#!42-Vb!fcO{+y#RcL!AsH8Hf0hha<> z1Y_<2Dxlaa;T2vo|3Ds;KtdvqYbMzRVH2p;1Tj7 zjF0hIUe$vkFxG7Rd_j|48~n5BmT_Dtr&3360H z6W0g^VVq%KkDFi1LJPnOz*vX3l8F8vOm;xk_pQ(O6m$|hgiSfM=_wr!Tnp6KDE?@t zn2@?WsP7yDa($-MicRxvP*unE6ys)PK}*_|xJTh3n&0JdLCYO^*skv&Ou*io9Wx+Xex0&~g=KX6l-1R_p0>c0AR&DnpR` zF%H1~*ezMv|6-5VcXe&DnAccMHadCA;zoD$Bpfd z+A6A^PlFcrP$yF z)v(F)z^WaQK9W`%B6j?8a5wv2jC#*D=<&3bowW4~rL(Z8#%=a}N@68ZV7| znaA!%zT!qXuIg$zbpajtMiB?hSMy%uvt|c+))Dc{#J>{Eqw=rgA)v{?x04$+*cxIg z-?sBAaFORg-O7YKVV}cBUhhh~UxyAEsA1;$s=w=UrCIL+Q>md%w^3@Y;*B>_%7a;6 zgfRjls1Xd>BMTcv7~smMTp^zgKr!2sd>5%t-Zm>WXZ!Bnijc*beKY;#BHdpN?TSc?O`cID;&fX&WNT*YWxF19yMML)X6UBm8k7sG z(65Uze1BOPFCY?Ep11qoHldA;{2w1BXj7!1iuu4_%POKY&l|Lt(KrJ^NdZ=vB~@(n z`!HwBluh3mYIVjx61)dH3)zidnw$hpGP2A6{+*6&9`*Mt2jA~bI46c=hYmQ={qUB^<7+vw=u;+B6(4f=i@SSMhqmKLu1lr}g8J3)uwW6_)@21Z4M(p3P_;{X*OF-U{?P1c3lG{~mi zF2wEGzGyPy>RCFNG_kH_TUQW�&t{6o!F`noeP)!aWmzbXt5Rv(n$2?qb+YAqlTf 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

-zD5D_;-GDG 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%0AI#9kC<7P>VF)q411vDc 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 literal 0 HcmV?d00001 From 8a54901621992f420fd55e3363afdcadc40bde1a Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Wed, 7 Aug 2019 10:46:01 -0400 Subject: [PATCH 125/632] Version bump edits --- CHANGELOG.md | 4 +++- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec054484..7186cc24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ -## 0.8.0-dev +## 0.9.0-dev + +## 0.8.0 **BREAKING CHANGES** - [#175](https://github.com/Datatamer/unify-client-python/issues/175) `AttributeCollection` no longer has a `from_json` method or a `data` parameter in its constructor - `AttributeType` no longer inherits from `BaseResource` (no API path), removing its `from_json` method and `relative_id` property diff --git a/pyproject.toml b/pyproject.toml index b3daa5b3..6a3aa612 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "tamr-unify-client" -version = "0.8.0-dev" +version = "0.9.0-dev" description = "Python Client for the Tamr Unify API" license = "Apache-2.0" authors = ["Pedro Cattori "] From 4c7babc7cec0398b4bf5e087890d2aaf1ef753ad Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 6 Aug 2019 14:25:41 -0400 Subject: [PATCH 126/632] attribute collection getters --- CHANGELOG.md | 2 ++ tamr_unify_client/attribute/collection.py | 11 +++-------- tests/unit/test_dataset_attributes.py | 4 +++- tests/unit/test_project.py | 6 ++++++ 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7186cc24..200a3b6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ ## 0.9.0-dev + **BUG FIXES** + - [#235](https://github.com/Datatamer/unify-client-python/issues/235) Making `AttributeCollection` retrieve attributes directly instead of by streaming ## 0.8.0 **BREAKING CHANGES** diff --git a/tamr_unify_client/attribute/collection.py b/tamr_unify_client/attribute/collection.py index b7d0d7f2..4b68e0c0 100644 --- a/tamr_unify_client/attribute/collection.py +++ b/tamr_unify_client/attribute/collection.py @@ -23,7 +23,7 @@ def by_resource_id(self, resource_id): :returns: The specified attribute. :rtype: :class:`~tamr_unify_client.attribute.resource.Attribute` """ - return self.by_name(resource_id) + return super().by_resource_id(self.api_path, resource_id) def by_relative_id(self, relative_id): """Retrieve an attribute by relative ID. @@ -33,8 +33,7 @@ def by_relative_id(self, relative_id): :returns: The specified attribute. :rtype: :class:`~tamr_unify_client.attribute.resource.Attribute` """ - resource_id = relative_id.split("/")[-1] - return self.by_resource_id(resource_id) + return super().by_relative_id(Attribute, relative_id) def by_external_id(self, external_id): """Retrieve an attribute by external ID. @@ -76,12 +75,8 @@ def by_name(self, attribute_name): :type attribute_name: str :return: Attribute with matching name in this collection. :rtype: :class:`~tamr_unify_client.attribute.resource.Attribute` - :raises KeyError: If no attribute with specified name was found. """ - for attribute in self: - if attribute.name == attribute_name: - return attribute - raise KeyError(f"No attribute found with name: {attribute_name}") + return super().by_resource_id(self.api_path, attribute_name) def create(self, creation_spec): """ diff --git a/tests/unit/test_dataset_attributes.py b/tests/unit/test_dataset_attributes.py index f6df2485..9c545926 100644 --- a/tests/unit/test_dataset_attributes.py +++ b/tests/unit/test_dataset_attributes.py @@ -26,7 +26,9 @@ def test_dataset_attributes(): status=204, ) responses.add( - responses.GET, dataset_url + "/attributes", json=[attribute_creation_spec] + responses.GET, + dataset_url + "/attributes/myAttribute", + json=attribute_creation_spec, ) dataset = unify.datasets.by_resource_id("1") diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index 28496000..0d4b9b77 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -56,6 +56,12 @@ def test_project_attributes_get(self): project = self.unify.projects.by_external_id(self.project_external_id) attributes = list(project.attributes) self.assertEqual(len(self.project_attributes_json), len(attributes)) + + responses.add( + responses.GET, + self.project_attributes_url + "/id", + json=self.project_attributes_json[0], + ) id_attribute = project.attributes.by_name("id") self.assertEqual(self.project_attributes_json[0]["name"], id_attribute.name) From 78010e9732bd147a4dc5f58df3919581543ae281 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 6 Aug 2019 13:53:44 -0400 Subject: [PATCH 127/632] delete a base resource --- tamr_unify_client/base_resource.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tamr_unify_client/base_resource.py b/tamr_unify_client/base_resource.py index 7e106f02..382d97f3 100644 --- a/tamr_unify_client/base_resource.py +++ b/tamr_unify_client/base_resource.py @@ -32,3 +32,12 @@ def resource_id(self): if rid is None: return None return rid.split("/")[-1] + + def delete(self): + """Deletes this resource. Some resources do not support deletion, and will raise a 405 error if this is called. + + :return: HTTP response from the server + :rtype: :class:`requests.Response` + """ + response = self.client.delete(self.api_path).successful() + return response From 733b3f1ff1dbe34e24bf3a715fd1f3e88c290cb9 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 6 Aug 2019 13:54:15 -0400 Subject: [PATCH 128/632] test resource delete --- tests/unit/test_attribute.py | 19 +++++++++ tests/unit/test_attribute_configuration.py | 49 ++++++++++++++++------ tests/unit/test_dataset.py | 49 ++++++++++++++++++++++ tests/unit/test_published_clusters.py | 12 ++++++ tests/unit/test_taxonomy.py | 34 +++++++++++++++ 5 files changed, 151 insertions(+), 12 deletions(-) create mode 100644 tests/unit/test_dataset.py diff --git a/tests/unit/test_attribute.py b/tests/unit/test_attribute.py index 44ac05b1..d4045473 100644 --- a/tests/unit/test_attribute.py +++ b/tests/unit/test_attribute.py @@ -1,10 +1,12 @@ from unittest import TestCase +from requests import HTTPError import responses from tamr_unify_client import Client from tamr_unify_client.attribute.resource import Attribute from tamr_unify_client.auth import UsernamePasswordAuth +from tamr_unify_client.dataset.resource import Dataset class TestAttribute(TestCase): @@ -72,6 +74,23 @@ def test_dataset_attributes(self): alias = "datasets/1/attributes/RowNum" self.assertEqual(alias, attributes[0].relative_id) + @responses.activate + def test_delete_attribute(self): + url = f"http://localhost:9100/api/versioned/v1/datasets/1/attributes/RowNum" + responses.add(responses.GET, url, json=self._attributes_json[0]) + responses.add(responses.DELETE, url, status=204) + responses.add(responses.GET, url, status=404) + + dataset = Dataset(self.unify, self._dataset_json) + attribute = dataset.attributes.by_resource_id("RowNum") + self.assertEqual(attribute._data, self._attributes_json[0]) + + response = attribute.delete() + self.assertEqual(response.status_code, 204) + self.assertRaises( + HTTPError, lambda: dataset.attributes.by_resource_id("RowNum") + ) + _dataset_json = { "id": "unify://unified-data/v1/datasets/1", "externalId": "number 1", diff --git a/tests/unit/test_attribute_configuration.py b/tests/unit/test_attribute_configuration.py index 86ef569f..bceed9ad 100644 --- a/tests/unit/test_attribute_configuration.py +++ b/tests/unit/test_attribute_configuration.py @@ -1,7 +1,13 @@ from unittest import TestCase +from requests import HTTPError +import responses + from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth +from tamr_unify_client.project.attribute_configuration.collection import ( + AttributeConfigurationCollection, +) from tamr_unify_client.project.attribute_configuration.resource import ( AttributeConfiguration, ) @@ -14,42 +20,61 @@ def setUp(self): def test_resource(self): alias = "projects/1/attributeConfigurations/26" - test = AttributeConfiguration(self.unify, self.ac_json, alias) + test = AttributeConfiguration(self.unify, self._ac_json, alias) expected = alias self.assertEqual(expected, test.relative_id) - expected = self.ac_json["id"] + expected = self._ac_json["id"] self.assertEqual(expected, test.id) - expected = self.ac_json["relativeAttributeId"] + expected = self._ac_json["relativeAttributeId"] self.assertEqual(expected, test.relative_attribute_id) - expected = self.ac_json["attributeRole"] + expected = self._ac_json["attributeRole"] self.assertEqual(expected, test.attribute_role) - expected = self.ac_json["similarityFunction"] + expected = self._ac_json["similarityFunction"] self.assertEqual(expected, test.similarity_function) - expected = self.ac_json["enabledForMl"] + expected = self._ac_json["enabledForMl"] self.assertEqual(expected, test.enabled_for_ml) - expected = self.ac_json["tokenizer"] + expected = self._ac_json["tokenizer"] self.assertEqual(expected, test.tokenizer) - expected = self.ac_json["numericFieldResolution"] + expected = self._ac_json["numericFieldResolution"] self.assertEqual(expected, test.numeric_field_resolution) - expected = self.ac_json["attributeName"] + expected = self._ac_json["attributeName"] self.assertEqual(expected, test.attribute_name) def test_resource_from_json(self): alias = "projects/1/attributeConfigurations/26" - expected = AttributeConfiguration(self.unify, self.ac_json, alias) - actual = AttributeConfiguration.from_json(self.unify, self.ac_json, alias) + expected = AttributeConfiguration(self.unify, self._ac_json, alias) + actual = AttributeConfiguration.from_json(self.unify, self._ac_json, alias) self.assertEqual(repr(expected), repr(actual)) - ac_json = { + @responses.activate + def test_delete(self): + base = "http://localhost:9100/api/versioned/v1" + alias = "projects/1/attributeConfigurations" + attribute_id = "26" + + url = f"{base}/{alias}/{attribute_id}" + responses.add(responses.GET, url, json=self._ac_json) + responses.add(responses.DELETE, url, status=204) + responses.add(responses.GET, url, status=404) + + collection = AttributeConfigurationCollection(self.unify, alias) + config = collection.by_resource_id(attribute_id) + self.assertEqual(config._data, self._ac_json) + + response = config.delete() + self.assertEqual(response.status_code, 204) + self.assertRaises(HTTPError, lambda: collection.by_resource_id(attribute_id)) + + _ac_json = { "id": "unify://unified-data/v1/projects/1/attributeConfigurations/26", "relativeId": "projects/1/attributeConfigurations/26", "relativeAttributeId": "datasets/8/attributes/surname", diff --git a/tests/unit/test_dataset.py b/tests/unit/test_dataset.py new file mode 100644 index 00000000..8f02c809 --- /dev/null +++ b/tests/unit/test_dataset.py @@ -0,0 +1,49 @@ +from unittest import TestCase + +from requests import HTTPError +import responses + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth + + +class TestAttribute(TestCase): + def setUp(self): + auth = UsernamePasswordAuth("username", "password") + self.unify = Client(auth) + + @responses.activate + def test_delete(self): + url = "http://localhost:9100/api/versioned/v1/datasets/1" + responses.add(responses.GET, url, json=self._dataset_json) + responses.add(responses.DELETE, url, status=204) + responses.add(responses.GET, url, status=404) + + dataset = self.unify.datasets.by_resource_id("1") + self.assertEqual(dataset._data, self._dataset_json) + + response = dataset.delete() + self.assertEqual(response.status_code, 204) + self.assertRaises(HTTPError, lambda: self.unify.datasets.by_resource_id("1")) + + _dataset_json = { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "1", + "name": "dataset 1 name", + "description": "dataset 1 description", + "version": "dataset 1 version", + "keyAttributeNames": ["tamr_id"], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version", + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version", + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [], + } diff --git a/tests/unit/test_published_clusters.py b/tests/unit/test_published_clusters.py index 908d8c06..beda2ae7 100644 --- a/tests/unit/test_published_clusters.py +++ b/tests/unit/test_published_clusters.py @@ -1,5 +1,6 @@ from unittest import TestCase +from requests import HTTPError import responses from tamr_unify_client import Client @@ -58,6 +59,17 @@ def test_published_clusters_configuration(self): config.versions_time_to_live, self._config_json["versionsTimeToLive"] ) + @responses.activate + def test_delete_published_clusters_configuration(self): + path = "projects/1/publishedClustersConfiguration" + config_url = f"{self._base_url}/{path}" + responses.add(responses.GET, config_url, json=self._config_json) + responses.add(responses.DELETE, config_url, status=405) + + p = Project(self.unify, self._project_config_json).as_mastering() + config = p.published_clusters_configuration() + self.assertRaises(HTTPError, config.delete) + @responses.activate def test_refresh_ids(self): unified_dataset_url = f"{self._base_url}/projects/1/unifiedDataset" diff --git a/tests/unit/test_taxonomy.py b/tests/unit/test_taxonomy.py index 6eac948a..fd09d130 100644 --- a/tests/unit/test_taxonomy.py +++ b/tests/unit/test_taxonomy.py @@ -2,6 +2,7 @@ import json from unittest import TestCase +from requests import HTTPError import responses from tamr_unify_client import Client @@ -9,6 +10,7 @@ from tamr_unify_client.categorization.category.collection import CategoryCollection from tamr_unify_client.categorization.category.resource import Category from tamr_unify_client.categorization.taxonomy import Taxonomy +from tamr_unify_client.project.resource import Project class TestTaxonomy(TestCase): @@ -98,6 +100,38 @@ def create_callback(request, snoop): sent.append(json.loads(line)) self.assertEqual(sent, creation_specs) + @responses.activate + def test_delete(self): + url = "http://localhost:9100/api/versioned/v1/projects/1/taxonomy" + responses.add(responses.GET, url, json=self._taxonomy_json) + responses.add(responses.DELETE, url, status=204) + responses.add(responses.GET, url, status=404) + + project = Project( + self.unify, {"type": "CATEGORIZATION"}, "projects/1" + ).as_categorization() + taxonomy = project.taxonomy() + self.assertEqual(taxonomy._data, self._taxonomy_json) + + response = taxonomy.delete() + self.assertEqual(response.status_code, 204) + self.assertRaises(HTTPError, project.taxonomy) + + @responses.activate + def test_delete_category(self): + url = "http://localhost:9100/api/versioned/v1/projects/1/taxonomy/categories/1" + responses.add(responses.GET, url, json=self._categories_json[0]) + responses.add(responses.DELETE, url, status=204) + responses.add(responses.GET, url, status=404) + + categories = CategoryCollection(self.unify, "projects/1/taxonomy/categories") + category = categories.by_resource_id("1") + self.assertEqual(category._data, self._categories_json[0]) + + response = category.delete() + self.assertEqual(response.status_code, 204) + self.assertRaises(HTTPError, lambda: categories.by_resource_id("1")) + _taxonomy_json = { "id": "unify://unified-data/v1/projects/1/taxonomy", "name": "Test Taxonomy", From 2805fc4b739b7dffd980b52af5e614aa435cd125 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 6 Aug 2019 16:47:27 -0400 Subject: [PATCH 129/632] changelog --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 200a3b6b..0151999d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ ## 0.9.0-dev + **NEW FEATURES** + - [#218](https://github.com/Datatamer/unify-client-python/issues/218) Delete a `BaseResource` + **BUG FIXES** - - [#235](https://github.com/Datatamer/unify-client-python/issues/235) Making `AttributeCollection` retrieve attributes directly instead of by streaming + - [#235](https://github.com/Datatamer/unify-client-python/issues/235) Making `AttributeCollection` retrieve attributes directly instead of by streaming ## 0.8.0 **BREAKING CHANGES** From c0eeb040599bdb643a05acfecdc34790cbf62073 Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Thu, 8 Aug 2019 11:01:19 -0400 Subject: [PATCH 130/632] Release edits. --- RELEASE.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 0046f821..119a3079 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -24,11 +24,16 @@ On the [Datatamer/unify-client-python](https://github.com/Datatamer/unify-client Then, create a branch on Github within the [Datatamer/unify-client-python](https://github.com/Datatamer/unify-client-python) repo titled `release-` e.g. `release-0.3.0`. +Create a branch locally with the following commands: +1. `git fetch Datatamer` (this will pull down the release branch you created on Github) +2. `git checkout Datatamer/release-0.3.0` (this will get you on the release branch) +3. `git checkout -b release-0.3.0` (creating a branch for you to make the ensuing edits in step 3) + NOTE: This release branch should *not* contain the version bump changes from Step 1. # 3. Remove `-dev` suffix on release branch -Create a PR *to the release branch* with the following changes: +Create a PR *to the release branch* (`Datatamer/release-0.3.0`) *from your release branch* (`my-github-username/release-0.3.0`)with the following changes: - `pyproject.toml`: Remove `-dev` suffix from version e.g. `0.3.0-dev` -> `0.3.0`. - `CHANGELOG.md`: Remove the `-dev` suffix from the version being released e.g. `# 0.3.0-dev` -> `# 0.3.0`. @@ -43,7 +48,7 @@ Title the release with the release version. Do not include anything else in the - Incorrect: `v0.3.0` - Incorrect: `Release 0.3.0` -Select the corresponding release branch in the `Target` branch dropdown. +**Select the corresponding release branch in the** `Target` **branch dropdown.** Copy/paste the `CHANGELOG.md` entries for this release into the description for the release (only the entries, not the header since the version number is already encoded as the title for this release). From 0df543d722e6094308f267a490a38f2873106526 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 9 Aug 2019 15:41:11 -0400 Subject: [PATCH 131/632] Update docs / build badges ... so that they correctly reference `tamr-client` --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f8bc8ae9..0721e710 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ Programmatically 💻 interact with Tamr Unify using Python 🐍 [![Version](https://img.shields.io/pypi/v/tamr-unify-client.svg?style=flat-square)](https://pypi.org/project/tamr-unify-client/) -[![Documentation Status](https://readthedocs.org/projects/tamr-unify-python-client/badge/?version=stable&style=flat-square)](https://tamr-unify-python-client.readthedocs.io/en/stable/?badge=stable) -[![Build Status](https://img.shields.io/travis/Datatamer/unify-client-python.svg?style=flat-square)](https://travis-ci.org/Datatamer/unify-client-python) +[![Documentation Status](https://readthedocs.org/projects/tamr-client/badge/?version=stable&style=flat-square)](https://tamr-client.readthedocs.io/en/stable/?badge=stable) +[![Build Status](https://img.shields.io/travis/Datatamer/tamr-client.svg?style=flat-square)](https://travis-ci.org/Datatamer/tamr-client) ![Supported Python Versions](https://img.shields.io/pypi/pyversions/tamr-unify-client.svg?style=flat-square) [![License](https://img.shields.io/pypi/l/tamr-unify-client.svg?style=flat-square)](LICENSE) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg?style=flat-square)](https://github.com/ambv/black) From f1c747c9e62e6a091fe8ee3da5391cdda24989f1 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Wed, 7 Aug 2019 14:09:44 -0400 Subject: [PATCH 132/632] remove an input dataset from a project --- CHANGELOG.md | 1 + tamr_unify_client/project/resource.py | 14 ++++++++++++++ tests/unit/test_project.py | 19 +++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0151999d..03b36785 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## 0.9.0-dev **NEW FEATURES** - [#218](https://github.com/Datatamer/unify-client-python/issues/218) Delete a `BaseResource` + - [#233](https://github.com/Datatamer/unify-client-python/issues/233) Remove an input dataset from a project **BUG FIXES** - [#235](https://github.com/Datatamer/unify-client-python/issues/235) Making `AttributeCollection` retrieve attributes directly instead of by streaming diff --git a/tamr_unify_client/project/resource.py b/tamr_unify_client/project/resource.py index 1923eb18..b5aab66d 100644 --- a/tamr_unify_client/project/resource.py +++ b/tamr_unify_client/project/resource.py @@ -110,6 +110,20 @@ def add_input_dataset(self, dataset): ).successful() return response + def remove_input_dataset(self, dataset): + """Remove a dataset from a project. + + :param dataset: The dataset to be removed from this project. + :type dataset: :class:`~tamr_unify_client.dataset.resource.Dataset` + :return: HTTP response from the server + :rtype: :class:`requests.Response` + """ + params = {"id": dataset.relative_id} + response = self.client.delete( + self.api_path + "/inputDatasets", params=params + ).successful() + return response + def input_datasets(self): """Retrieve a collection of this project's input datasets. diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index 0d4b9b77..1b0aa6ae 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -33,6 +33,25 @@ def test_project_add_input_dataset(self): input_datasets = project.client.get(alias).successful().json() self.assertEqual(self.dataset_json, input_datasets) + @responses.activate + def test_project_remove_input_dataset(self): + dataset_id = self.dataset_json[0]["relativeId"] + + responses.add(responses.GET, self.input_datasets_url, json=self.dataset_json) + responses.add( + responses.DELETE, f"{self.input_datasets_url}?id={dataset_id}", status=204 + ) + responses.add(responses.GET, self.input_datasets_url, json=[]) + + project = Project(self.unify, self.project_json[0]) + dataset = next(project.input_datasets().stream()) + + response = project.remove_input_dataset(dataset) + self.assertEqual(response.status_code, 204) + + input_datasets = project.input_datasets() + self.assertEqual(list(input_datasets), []) + @responses.activate def test_project_by_external_id__raises_when_not_found(self): responses.add(responses.GET, self.projects_url, json=[]) From f6646ad4bd175bf36629fa2710e39def53f4e3ed Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Mon, 5 Aug 2019 09:52:22 -0400 Subject: [PATCH 133/632] adding pandas to dev dependencies --- docs/conf.py | 1 + poetry.lock | 37 ++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 129ad8e3..ef24e7fc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -52,6 +52,7 @@ intersphinx_mapping = { "https://docs.python.org/": None, "requests": ("http://docs.python-requests.org/en/master/", None), + "pandas": ("https://pandas.pydata.org/pandas-docs/stable", None), } # Add any paths that contain templates here, relative to this directory. diff --git a/poetry.lock b/poetry.lock index 69f704b0..b7864835 100644 --- a/poetry.lock +++ b/poetry.lock @@ -193,6 +193,14 @@ optional = false python-versions = ">=3.4" version = "7.0.0" +[[package]] +category = "dev" +description = "NumPy is the fundamental package for array computing with Python." +name = "numpy" +optional = false +python-versions = ">=3.5" +version = "1.17.0" + [[package]] category = "dev" description = "Core utilities for Python packages" @@ -205,6 +213,19 @@ version = "19.0" pyparsing = ">=2.0.2" six = "*" +[[package]] +category = "dev" +description = "Powerful data structures for data analysis, time series, and statistics" +name = "pandas" +optional = false +python-versions = ">=3.5.3" +version = "0.25.0" + +[package.dependencies] +numpy = ">=1.13.3" +python-dateutil = ">=2.6.1" +pytz = ">=2017.2" + [[package]] category = "dev" description = "plugin and hook calling mechanisms for python" @@ -279,6 +300,17 @@ wcwidth = "*" python = ">=2.8" version = ">=4.0.0" +[[package]] +category = "dev" +description = "Extensions to the standard Python datetime module" +name = "python-dateutil" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +version = "2.8.0" + +[package.dependencies] +six = ">=1.5" + [[package]] category = "dev" description = "World timezone definitions, modern and historical" @@ -456,7 +488,7 @@ python-versions = ">=2.7" version = "0.5.1" [metadata] -content-hash = "0772a1be9e411424ce4a918a8d3e3dfa5627aa0f308ae6dd857c9fde6e3dced6" +content-hash = "dfe6fe8cf933d15d1f203b1e457ec5110eeecff55a0c02394390ef2d693b235f" python-versions = "^3.6" [metadata.hashes] @@ -481,7 +513,9 @@ jinja2 = ["065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013", "1 markupsafe = ["00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", "09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", "09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", "1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", "24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", "29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", "43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", "46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", "500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", "535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", "62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", "6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", "717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", "79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", "7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", "88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", "8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", "98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", "9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", "9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", "ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", "b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", "b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", "b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", "ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", "c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", "cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", "e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"] mccabe = ["ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"] more-itertools = ["2112d2ca570bb7c3e53ea1a35cd5df42bb0fd10c45f0fb97178679c3c03d64c7", "c3e4748ba1aad8dba30a4886b0b1a2004f9a863837b8654e7059eebf727afa5a"] +numpy = ["03e311b0a4c9f5755da7d52161280c6a78406c7be5c5cc7facfbcebb641efb7e", "0cdd229a53d2720d21175012ab0599665f8c9588b3b8ffa6095dd7b90f0691dd", "312bb18e95218bedc3563f26fcc9c1c6bfaaf9d453d15942c0839acdd7e4c473", "464b1c48baf49e8505b1bb754c47a013d2c305c5b14269b5c85ea0625b6a988a", "5adfde7bd3ee4864536e230bcab1c673f866736698724d5d28c11a4d63672658", "7724e9e31ee72389d522b88c0d4201f24edc34277999701ccd4a5392e7d8af61", "8d36f7c53ae741e23f54793ffefb2912340b800476eb0a831c6eb602e204c5c4", "910d2272403c2ea8a52d9159827dc9f7c27fb4b263749dca884e2e4a8af3b302", "951fefe2fb73f84c620bec4e001e80a80ddaa1b84dce244ded7f1e0cbe0ed34a", "9588c6b4157f493edeb9378788dcd02cb9e6a6aeaa518b511a1c79d06cbd8094", "9ce8300950f2f1d29d0e49c28ebfff0d2f1e2a7444830fbb0b913c7c08f31511", "be39cca66cc6806652da97103605c7b65ee4442c638f04ff064a7efd9a81d50a", "c3ab2d835b95ccb59d11dfcd56eb0480daea57cdf95d686d22eff35584bc4554", "eb0fc4a492cb896346c9e2c7a22eae3e766d407df3eb20f4ce027f23f76e4c54", "ec0c56eae6cee6299f41e780a0280318a93db519bbb2906103c43f3e2be1206c", "f4e4612de60a4f1c4d06c8c2857cdcb2b8b5289189a12053f37d3f41f06c60d0"] packaging = ["0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af", "9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3"] +pandas = ["074a032f99bb55d178b93bd98999c971542f19317829af08c99504febd9e9b8b", "20f1728182b49575c2f6f681b3e2af5fac9e84abdf29488e76d569a7969b362e", "2745ba6e16c34d13d765c3657bb64fa20a0e2daf503e6216a36ed61770066179", "32c44e5b628c48ba17703f734d59f369d4cdcb4239ef26047d6c8a8bfda29a6b", "3b9f7dcee6744d9dcdd53bce19b91d20b4311bf904303fa00ef58e7df398e901", "544f2033250980fb6f069ce4a960e5f64d99b8165d01dc39afd0b244eeeef7d7", "58f9ef68975b9f00ba96755d5702afdf039dea9acef6a0cfd8ddcde32918a79c", "9023972a92073a495eba1380824b197ad1737550fe1c4ef8322e65fe58662888", "914341ad2d5b1ea522798efa4016430b66107d05781dbfe7cf05eba8f37df995", "9d151bfb0e751e2c987f931c57792871c8d7ff292bcdfcaa7233012c367940ee", "b932b127da810fef57d427260dde1ad54542c136c44b227a1e367551bb1a684b", "cfb862aa37f4dd5be0730731fdb8185ac935aba8b51bf3bd035658111c9ee1c9", "de7ecb4b120e98b91e8a2a21f186571266a8d1faa31d92421e979c7ca67d8e5c", "df7e1933a0b83920769611c5d6b9a1bf301e3fa6a544641c6678c67621fe9843"] pluggy = ["0825a152ac059776623854c1543d65a4ad408eb3d33ee114dff91e57ec6ae6fc", "b9817417e95936bf75d85d3f8767f7df6cdde751fc40aed3bb3074cbcb77757c"] py = ["64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", "dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"] pycodestyle = ["95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", "e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"] @@ -489,6 +523,7 @@ pyflakes = ["17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", pygments = ["71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", "881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297"] pyparsing = ["1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a", "9b6323ef4ab914af344ba97510e966d64ba91055d6b9afa6b30799340e89cc03"] pytest = ["6032845e68a17a96e8da3088037f899b56357769a724122056265ca2ea1890ee", "bea27a646a3d74cbbcf8d3d4a06b2dfc336baf3dc2cc85cf70ad0157e73e8322"] +python-dateutil = ["7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb", "c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e"] pytz = ["303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda", "d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141"] requests = ["11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", "9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"] responses = ["502d9c0c8008439cfcdef7e251f507fcfdd503b56e8c0c87c3c3e3393953f790", "97193c0183d63fba8cd3a041c75464e4b09ea0aff6328800d1546598567dde0b"] diff --git a/pyproject.toml b/pyproject.toml index 6a3aa612..4d9fd37a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ black = {version = "^19.3b0",allows-prereleases = true} flake8 = "^3.7" toml = "^0.10.0" sphinx_rtd_theme = "^0.4.3" +pandas = "^0.25.0" [build-system] requires = ["poetry>=0.12"] From f5a39d5c87a72c287a78f06bb68e0e90f1b58782 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Mon, 5 Aug 2019 09:52:43 -0400 Subject: [PATCH 134/632] dataset from dataframe function --- tamr_unify_client/dataset/collection.py | 88 +++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/tamr_unify_client/dataset/collection.py b/tamr_unify_client/dataset/collection.py index b23f70fc..1501aeb4 100644 --- a/tamr_unify_client/dataset/collection.py +++ b/tamr_unify_client/dataset/collection.py @@ -1,3 +1,5 @@ +from requests.exceptions import HTTPError + from tamr_unify_client.base_collection import BaseCollection from tamr_unify_client.dataset.resource import Dataset @@ -89,4 +91,90 @@ def create(self, creation_spec): data = self.client.post(self.api_path, json=creation_spec).successful().json() return Dataset.from_json(self.client, data) + def create_from_dataframe( + self, df, primary_key_name, dataset_name, ignore_nan=True + ): + """Creates a dataset in this collection with the given name, creates an attribute for each column in the `df` + (with `primary_key_name` as the key attribute), and upserts a record for each row of `df`. + + Each attribute has the default type `ARRAY[STRING]`, besides the key attribute, which will have type `STRING`. + + This function attempts to ensure atomicity, but it is not guaranteed. If an error occurs while creating + attributes or records, an attempt will be made to delete the dataset that was created. However, if this + request errors, it will not try again. + + :param df: The data to create the dataset with. + :type df: :class:`pandas.DataFrame` + :param primary_key_name: The name of the primary key of the dataset. Must be a column of `df`. + :type primary_key_name: str + :param dataset_name: What to name the dataset in Unify. There cannot already be a dataset with this name. + :type dataset_name: str + :param ignore_nan: Whether to convert `NaN` values to `null` before upserting records to Unify. If `False` and + `NaN` is in `df`, this function will fail. Optional, default is `True`. + :type ignore_nan: bool + :returns: The newly created dataset. + :rtype: :class:`~tamr_unify_client.dataset.resource.Dataset` + :raises KeyError: If `primary_key_name` is not a column in `df`. + :raises CreationError: If a step in creating the dataset fails. + """ + if primary_key_name not in df.columns: + raise KeyError(f"{primary_key_name} is not an attribute of the data") + + creation_spec = {"name": dataset_name, "keyAttributeNames": [primary_key_name]} + try: + dataset = self.create(creation_spec) + except HTTPError: + raise CreationError("Dataset was not created") + # after this point, if a request fails, try to undo the change by deleting this dataset + + attributes = dataset.attributes + for col in df.columns: + if col == primary_key_name: + # this attribute already exists, so don't create it again + continue + + attr_spec = { + "name": col, + "type": {"baseType": "ARRAY", "innerType": {"baseType": "STRING"}}, + } + try: + attributes.create(attr_spec) + except HTTPError: + self._handle_creation_failure(dataset, "An attribute was not created") + + records = df.to_dict(orient="records") + try: + response = dataset.upsert_records( + records, primary_key_name, ignore_nan=ignore_nan + ) + except HTTPError: + self._handle_creation_failure(dataset, "Records could not be created") + + if not response["allCommandsSucceeded"]: + self._handle_creation_failure(dataset, "Some records had validation errors") + + return dataset + + def _handle_creation_failure(self, dataset, error): + """Attempts to make create_from_dataframe atomic by deleting the created dataset in the event of later failure. + However, this does not guarantee atomicity: if the request to delete the dataset fails, it will not retry. + + :param dataset: The created dataset to delete. + :type dataset: :class:`~tamr_unify_client.dataset.resource.Dataset` + :param error: The error that caused the function to fail. + :type error: str + """ + try: + dataset.delete() + except HTTPError: + raise CreationError("Created dataset didn't delete after an earlier error") + raise CreationError(error) + # super.__repr__ is sufficient + + +class CreationError(Exception): + """An error from :func:`~tamr_unify_client.dataset.collection.DatasetCollection.create_from_dataframe`""" + + def __init__(self, error_message): + super().__init__(error_message) From b443467a2436dfd718dc12695070eaa3dbf3664e Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 8 Aug 2019 13:28:44 -0400 Subject: [PATCH 135/632] test dataset from dataframe --- tests/unit/test_create_dataset.py | 173 ++++++++++++++++++++++++++++-- 1 file changed, 166 insertions(+), 7 deletions(-) diff --git a/tests/unit/test_create_dataset.py b/tests/unit/test_create_dataset.py index a2d775d0..72227970 100644 --- a/tests/unit/test_create_dataset.py +++ b/tests/unit/test_create_dataset.py @@ -1,12 +1,17 @@ +from functools import partial import json +from pandas import DataFrame +import pytest import responses from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth +from tamr_unify_client.dataset.collection import CreationError + auth = UsernamePasswordAuth("username", "password") -unify = Client(auth) +tamr = Client(auth) @responses.activate @@ -19,15 +24,169 @@ def test_create_dataset(): "externalId": "Dataset created with pubapi", } - datasets_url = f"http://localhost:9100/api/versioned/v1/datasets" - dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/1" - - responses.add(responses.POST, datasets_url, json=creation_spec, status=204) + dataset_url = _datasets_url + "/1" + responses.add(responses.POST, _datasets_url, json=creation_spec, status=201) responses.add(responses.GET, dataset_url, json=creation_spec) - u = unify.datasets.create(creation_spec) - p = unify.datasets.by_resource_id("1") + u = tamr.datasets.create(creation_spec) + p = tamr.datasets.by_resource_id("1") + assert u.name == p.name assert u.key_attribute_names == p.key_attribute_names assert u.description == p.description assert u.external_id == p.external_id + + +@responses.activate +def test_create_from_dataframe(): + def create_callback(request, snoop): + snoop["creation"] = json.loads(request.body) + return 201, {}, json.dumps(_dataset_json) + + def attribute_callback(request, snoop): + snoop["attribute"] = json.loads(request.body) + return 201, {}, json.dumps(_attribute_json) + + def record_callback(request, snoop): + snoop["records"] = [json.loads(r) for r in request.body] + return 200, {}, json.dumps(_records_response_json) + + snoop_dict = {} + responses.add_callback( + responses.POST, _datasets_url, partial(create_callback, snoop=snoop_dict) + ) + responses.add_callback( + responses.POST, _attribute_url, partial(attribute_callback, snoop=snoop_dict) + ) + # only one additional attribute should be created, as the pk is handled at dataset creation + responses.add(responses.POST, _attribute_url, status=500) + responses.add_callback( + responses.POST, _records_url, partial(record_callback, snoop=snoop_dict) + ) + + dataset = tamr.datasets.create_from_dataframe(_dataframe, "attribute1", "Dataset") + assert dataset.name == _dataset_json["name"] + + creation_spec = snoop_dict["creation"] + assert creation_spec["name"] == _dataset_json["name"] + assert creation_spec["keyAttributeNames"], ["attribute1"] + + attribute_spec = snoop_dict["attribute"] + assert attribute_spec["name"] == _attribute_json["name"] + assert attribute_spec["type"] == _attribute_json["type"] + + records_spec = snoop_dict["records"] + assert len(records_spec) == len(_records_json) + for command, record in zip(records_spec, _records_json): + assert command["action"] == "CREATE" + assert command["record"] == record + + +def test_key_not_in_dataframe(): + with pytest.raises(KeyError): + tamr.datasets.create_from_dataframe(_dataframe, "bad key", "Dataset") + + +@responses.activate +def test_creation_initial_failure(): + responses.add(responses.POST, _datasets_url, status=500) + responses.add(responses.DELETE, _datasets_url + "/1", status=204) + + with pytest.raises(CreationError): + tamr.datasets.create_from_dataframe(_dataframe, "attribute1", "Dataset") + + +@responses.activate +def test_attribute_creation_failure(): + responses.add(responses.POST, _datasets_url, json=_dataset_json) + responses.add(responses.POST, _attribute_url, status=500) + responses.add(responses.DELETE, _dataset_url, status=204) + + with pytest.raises(CreationError): + tamr.datasets.create_from_dataframe(_dataframe, "attribute1", "Dataset") + + +@responses.activate +def test_record_failure(): + responses.add(responses.POST, _datasets_url, json=_dataset_json) + responses.add(responses.POST, _attribute_url, json=_attribute_json) + responses.add(responses.POST, _records_url, status=500) + responses.add(responses.DELETE, _dataset_url, status=204) + + with pytest.raises(CreationError): + tamr.datasets.create_from_dataframe(_dataframe, "attribute1", "Dataset") + + +@responses.activate +def test_record_validation_failure(): + responses.add(responses.POST, _datasets_url, json=_dataset_json) + responses.add(responses.POST, _attribute_url, json=_attribute_json) + responses.add(responses.POST, _records_url, json=_records_failure_json) + responses.add(responses.DELETE, _dataset_url, status=204) + + with pytest.raises(CreationError): + tamr.datasets.create_from_dataframe(_dataframe, "attribute1", "Dataset") + + +@responses.activate +def test_dataset_deletion_failure(): + responses.add(responses.POST, _datasets_url, json=_dataset_json) + responses.add(responses.POST, _attribute_url, json=_attribute_json) + responses.add(responses.POST, _records_url, json=_records_failure_json) + responses.add(responses.DELETE, _dataset_url, status=500) + + with pytest.raises(CreationError): + tamr.datasets.create_from_dataframe(_dataframe, "attribute1", "Dataset") + + +_datasets_url = f"http://localhost:9100/api/versioned/v1/datasets" +_dataset_url = _datasets_url + "/1" +_attribute_url = _dataset_url + "/attributes" +_records_url = _dataset_url + ":updateRecords" + +_records_json = [ + {"attribute1": 1, "attribute2": "hi"}, + {"attribute1": 2, "attribute2": "record"}, +] +_dataframe = DataFrame(_records_json, columns=["attribute1", "attribute2"]) + +_records_response_json = { + "numCommandsProcessed": 2, + "allCommandsSucceeded": True, + "validationErrors": [], +} + +_records_failure_json = { + "numCommandsProcessed": 2, + "allCommandsSucceeded": False, + "validationErrors": [], +} + +_dataset_json = { + "id": "unify://unified-data/v1/datasets/1", + "name": "Dataset", + "description": "", + "version": "1", + "keyAttributeNames": ["attribute1"], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "1", + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "1", + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [], + "externalId": "Dataset", +} + +_attribute_json = { + "name": "attribute2", + "description": "", + "type": {"baseType": "ARRAY", "innerType": {"baseType": "STRING"}}, + "isNullable": False, +} From c39216063369b5870cbe5bbc7873cda9a9e270c8 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 8 Aug 2019 13:42:59 -0400 Subject: [PATCH 136/632] docs and changelog --- CHANGELOG.md | 1 + docs/developer-interface.rst | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03b36785..dd4eaea6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ **NEW FEATURES** - [#218](https://github.com/Datatamer/unify-client-python/issues/218) Delete a `BaseResource` - [#233](https://github.com/Datatamer/unify-client-python/issues/233) Remove an input dataset from a project + - [#67](https://github.com/Datatamer/unify-client-python/issues/67) Create a dataset from a pandas `DataFrame` **BUG FIXES** - [#235](https://github.com/Datatamer/unify-client-python/issues/235) Making `AttributeCollection` retrieve attributes directly instead of by streaming diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index 1faf0064..6e39e796 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -83,6 +83,8 @@ Dataset Collection .. autoclass:: tamr_unify_client.dataset.collection.DatasetCollection :members: +.. autoclass:: tamr_unify_client.dataset.collection.CreationError + :members: Dataset Profile ^^^^^^^^^^^^^^^ From 2c7fcf210d80862a35bc488a40bd700d234c0442 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 12 Aug 2019 14:52:15 -0400 Subject: [PATCH 137/632] Remove "unify" from README, fix broken links --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0721e710..134096e9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Python Client -Programmatically 💻 interact with Tamr Unify using Python 🐍 +Programmatically 💻 interact with Tamr using Python 🐍 [![Version](https://img.shields.io/pypi/v/tamr-unify-client.svg?style=flat-square)](https://pypi.org/project/tamr-unify-client/) [![Documentation Status](https://readthedocs.org/projects/tamr-client/badge/?version=stable&style=flat-square)](https://tamr-client.readthedocs.io/en/stable/?badge=stable) @@ -11,11 +11,11 @@ Programmatically 💻 interact with Tamr Unify using Python 🐍 --- *Quick links:* -**[Docs](https://tamr-unify-python-client.readthedocs.io/en/stable/)** | -**[Contributing](https://tamr-unify-python-client.readthedocs.io/en/stable/contributor-guide.html)** | -**[Code of Conduct](https://github.com/Datatamer/unify-client-python/blob/master/CODE_OF_CONDUCT.md)** | -**[Change Log](https://github.com/Datatamer/unify-client-python/blob/master/CHANGELOG.md)** | -**[License](https://github.com/Datatamer/unify-client-python/blob/master/LICENSE)** +**[Docs](https://tamr-client.readthedocs.io/en/stable/)** | +**[Contributing](https://tamr-client.readthedocs.io/en/stable/contributor-guide.html)** | +**[Code of Conduct](https://github.com/Datatamer/tamr-client/blob/master/CODE_OF_CONDUCT.md)** | +**[Change Log](https://github.com/Datatamer/tamr-client/blob/master/CHANGELOG.md)** | +**[License](https://github.com/Datatamer/tamr-client/blob/master/LICENSE)** --- @@ -32,9 +32,9 @@ pip install tamr-unify-client - Continuous Categorization - 🚀 Kick-off synchronous/asynchronous operations - Refresh datasets in your pipeline - - Train Tamr Unify's machine learning models + - Train Tamr's machine learning models - Generate predictions from trained models -- 🔒 Authenticate with Tamr Unify +- 🔒 Authenticate with Tamr - 📥 Fetch resources (e.g projects) by resource ID (e.g. `"1"`) - 📝 Read resource metadata - 🔁 Iterate over collections From 00b629e5dae4c4b643c93b2bd259241e661497b7 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 13 Aug 2019 09:33:14 -0400 Subject: [PATCH 138/632] fix clusters with data refresh --- CHANGELOG.md | 1 + tamr_unify_client/mastering/project.py | 12 +++++--- .../unit/test_published_clusters_with_data.py | 28 +++++++++++++++++++ tests/unit/test_record_clusters_with_data.py | 28 +++++++++++++++++++ 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd4eaea6..666aea46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ **BUG FIXES** - [#235](https://github.com/Datatamer/unify-client-python/issues/235) Making `AttributeCollection` retrieve attributes directly instead of by streaming + - [#256](https://github.com/Datatamer/unify-client-python/issues/256) Record and published clusters refresh did not use the correct endpoint ## 0.8.0 **BREAKING CHANGES** diff --git a/tamr_unify_client/mastering/project.py b/tamr_unify_client/mastering/project.py index e94a045d..1d042bcf 100644 --- a/tamr_unify_client/mastering/project.py +++ b/tamr_unify_client/mastering/project.py @@ -202,9 +202,9 @@ def record_clusters_with_data(self): # being able to call refresh on resulting dataset. Until then, we grab # the dataset by constructing its name from the corresponding Unified Dataset's name name = unified_dataset.name + "_dedup_clusters_with_data" - return self.client.datasets.by_name(name) - - # super.__repr__ is sufficient + dataset = self.client.datasets.by_name(name) + dataset.api_path = self.api_path + "/recordClustersWithData" + return dataset def published_clusters_with_data(self): """Project's unified dataset with associated clusters. @@ -215,7 +215,9 @@ def published_clusters_with_data(self): unified_dataset = self.unified_dataset() name = unified_dataset.name + "_dedup_published_clusters_with_data" - return self.client.datasets.by_name(name) + dataset = self.client.datasets.by_name(name) + dataset.api_path = self.api_path + "/publishedClustersWithData" + return dataset def binning_model(self): """ @@ -229,3 +231,5 @@ def binning_model(self): # Cannot get this resource and so we hard code resource_json = {"relativeId": alias} return BinningModel.from_json(self.client, resource_json, alias) + + # super.__repr__ is sufficient diff --git a/tests/unit/test_published_clusters_with_data.py b/tests/unit/test_published_clusters_with_data.py index ba6fec70..86802bbe 100644 --- a/tests/unit/test_published_clusters_with_data.py +++ b/tests/unit/test_published_clusters_with_data.py @@ -31,6 +31,28 @@ def test_published_clusters_with_data(): "version": "251", } + refresh_json = { + "id": "93", + "type": "SPARK", + "description": "Publish clusters", + "status": { + "state": "SUCCEEDED", + "startTime": "2019-06-24T15:58:56.595Z", + "endTime": "2019-06-24T15:59:17.084Z", + }, + "created": { + "username": "admin", + "time": "2019-06-24T15:58:48.734Z", + "version": "2407", + }, + "lastModified": { + "username": "system", + "time": "2019-06-24T15:59:18.350Z", + "version": "2423", + }, + "relativeId": "operations/93", + } + datasets_json = [pcwd_json] unify = Client(UsernamePasswordAuth("username", "password")) @@ -42,10 +64,16 @@ def test_published_clusters_with_data(): f"http://localhost:9100/api/versioned/v1/projects/{project_id}/unifiedDataset" ) datasets_url = f"http://localhost:9100/api/versioned/v1/datasets" + refresh_url = project_url + "/publishedClustersWithData:refresh" responses.add(responses.GET, project_url, json=project_config) responses.add(responses.GET, unified_dataset_url, json=unified_dataset_json) responses.add(responses.GET, datasets_url, json=datasets_json) + responses.add(responses.POST, refresh_url, json=refresh_json) + project = unify.projects.by_resource_id(project_id) actual_pcwd_dataset = project.as_mastering().published_clusters_with_data() assert actual_pcwd_dataset.name == pcwd_json["name"] + + op = actual_pcwd_dataset.refresh(poll_interval_seconds=0) + assert op.succeeded() diff --git a/tests/unit/test_record_clusters_with_data.py b/tests/unit/test_record_clusters_with_data.py index 01c303c8..ce32e94d 100644 --- a/tests/unit/test_record_clusters_with_data.py +++ b/tests/unit/test_record_clusters_with_data.py @@ -32,6 +32,28 @@ def test_record_clusters_with_data(): "version": "251", } + refresh_json = { + "id": "93", + "type": "SPARK", + "description": "Clustering", + "status": { + "state": "SUCCEEDED", + "startTime": "2019-06-24T15:58:56.595Z", + "endTime": "2019-06-24T15:59:17.084Z", + }, + "created": { + "username": "admin", + "time": "2019-06-24T15:58:48.734Z", + "version": "2407", + }, + "lastModified": { + "username": "system", + "time": "2019-06-24T15:59:18.350Z", + "version": "2423", + }, + "relativeId": "operations/93", + } + datasets_json = [rcwd_json] unify = Client(UsernamePasswordAuth("username", "password")) @@ -43,10 +65,16 @@ def test_record_clusters_with_data(): f"http://localhost:9100/api/versioned/v1/projects/{project_id}/unifiedDataset" ) datasets_url = f"http://localhost:9100/api/versioned/v1/datasets" + refresh_url = project_url + "/recordClustersWithData:refresh" responses.add(responses.GET, project_url, json=project_config) responses.add(responses.GET, unified_dataset_url, json=unified_dataset_json) responses.add(responses.GET, datasets_url, json=datasets_json) + responses.add(responses.POST, refresh_url, json=refresh_json) + project = unify.projects.by_resource_id(project_id) actual_rcwd_dataset = project.as_mastering().record_clusters_with_data() assert actual_rcwd_dataset.name == rcwd_json["name"] + + op = actual_rcwd_dataset.refresh(poll_interval_seconds=0) + assert op.succeeded() From 46f27687d59ca6d0233756dd1ab217685bfbb240 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 13 Aug 2019 09:58:48 -0400 Subject: [PATCH 139/632] remove instances of Unify from docstrings --- docs/user-guide/advanced-usage.rst | 6 +++--- docs/user-guide/faq.rst | 16 ++++++++-------- docs/user-guide/geo.rst | 12 ++++++------ docs/user-guide/quickstart.rst | 8 ++++---- tamr_unify_client/attribute/resource.py | 2 +- tamr_unify_client/auth/username_password.py | 4 ++-- tamr_unify_client/base_model.py | 2 +- tamr_unify_client/categorization/project.py | 2 +- tamr_unify_client/client.py | 12 ++++++------ tamr_unify_client/dataset/collection.py | 6 +++--- tamr_unify_client/dataset/profile.py | 2 +- tamr_unify_client/dataset/resource.py | 18 +++++++++--------- tamr_unify_client/dataset/status.py | 2 +- tamr_unify_client/dataset/uri.py | 2 +- tamr_unify_client/mastering/project.py | 16 ++++++++-------- tamr_unify_client/operation.py | 4 ++-- .../attribute_configuration/resource.py | 2 +- tamr_unify_client/project/collection.py | 2 +- tamr_unify_client/project/resource.py | 6 +++--- tamr_unify_client/project/step.py | 4 ++-- 20 files changed, 64 insertions(+), 64 deletions(-) diff --git a/docs/user-guide/advanced-usage.rst b/docs/user-guide/advanced-usage.rst index 41498b29..088d66e1 100644 --- a/docs/user-guide/advanced-usage.rst +++ b/docs/user-guide/advanced-usage.rst @@ -5,7 +5,7 @@ Asynchronous Operations ----------------------- You can opt-in to an asynchronous interface via the asynchronous keyword argument -for methods that kick-off Unify operations. +for methods that kick-off Tamr operations. E.g.:: @@ -68,8 +68,8 @@ We encourage you to use the high-level, object-oriented interface offered by the Python Client. If you aren't sure whether you need to send low-level HTTP requests, you probably don't. -But sometimes it's useful to directly send HTTP requests to Unify; for example, -Unify has many APIs that are not covered by the higher-level interface (most of +But sometimes it's useful to directly send HTTP requests to Tamr; for example, +Tamr has many APIs that are not covered by the higher-level interface (most of which are neither versioned nor supported). You can still call these endpoints using the Python Client, but you'll need to work with raw ``Response`` objects. diff --git a/docs/user-guide/faq.rst b/docs/user-guide/faq.rst index 708c7903..051de3a2 100644 --- a/docs/user-guide/faq.rst +++ b/docs/user-guide/faq.rst @@ -27,7 +27,7 @@ If you are already using the Python Client, you have 3 options: Upgrade to the latest stable version *even* if it has a different major version from what you currently use. -Note that you do not need to reason about the Unify API version nor the the Unify version. +Note that you do not need to reason about the Tamr API version nor the the Tamr version. ---- @@ -42,7 +42,7 @@ We'll illustrate with an example. Let's say you want to get a dataset by name in your Python code. **1.** If no such feature exists, you can file a Feature Request. Note that the Python -Client is limited by what the Unify API enables. So you should check if the Unify +Client is limited by what the Tamr API enables. So you should check if the Tamr API docs to see if the feature you want is even possible. **2.** If this feature already exists, you can try it out! @@ -54,10 +54,10 @@ E.g. ``unify.datasets.by_name(some_dataset_name)`` **2.b** If it fails with an HTTP error, it could be for 2 reasons: **2.a.i** It might be impossible to support that feature in the Python Client - because your Unify API version does not have the necessary endpoints to + because your Tamr API version does not have the necessary endpoints to support it. - **2.a.ii** Your Unify API version *does* support this feature with some endpoints, + **2.a.ii** Your Tamr API version *does* support this feature with some endpoints, but the Python Client know how to correctly implement this feature for this version of the API. In this case, you should submit a Feature Request. @@ -67,14 +67,14 @@ E.g. ``unify.datasets.by_name(some_dataset_name)`` .. note:: To see how to submit Bug Reports / Feature Requests, see :ref:`bug-reports-feature-requests`. - To check what endpoints your version of the Unify API supports, see `docs.tamr.com/reference `_ + To check what endpoints your version of the Tamr API supports, see `docs.tamr.com/reference `_ (be sure to select the correct version in the top left!). -How do I call custom endpoints, e.g. endpoints outside the Unify API? +How do I call custom endpoints, e.g. endpoints outside the Tamr API? --------------------------------------------------------------------- -To call a custom endpoint *within* the Unify API, use the ``client.request()`` method, and +To call a custom endpoint *within* the Tamr API, use the ``client.request()`` method, and provide an endpoint described by a path relative to ``base_path``. For example, if ``base_path`` is ``/api/versioned/v1/`` (the default), and you want to get ``/api/versioned/v1/projects/1``, you only need to provide ``projects/1`` (the relative ID provided by the project) as the endpoint, @@ -82,7 +82,7 @@ and the Client will resolve that into ``/api/versioned/v1/projects/1``. There are various APIs outside the ``/api/versioned/v1/`` prefix that are often useful or necessary to call - e.g. ``/api/service/health``, or other un-versioned / unsupported APIs. To call a custom -endpoint *outside* the Unify API, use the ``client.request()`` method, and provide an endpoint +endpoint *outside* the Tamr API, use the ``client.request()`` method, and provide an endpoint described by an *absolute* path (a path starting with ``/``). For example, to get ``/api/service/health`` (no matter what ``base_path`` is), call ``client.request()`` with ``/api/service/health`` as the endpoint. The Client will ignore ``base_path`` and send the diff --git a/docs/user-guide/geo.rst b/docs/user-guide/geo.rst index bc84a4e3..59463c27 100644 --- a/docs/user-guide/geo.rst +++ b/docs/user-guide/geo.rst @@ -26,7 +26,7 @@ There are three layers of information, modeled after GeoJSON; see https://tools. - coordinates (doubles; exactly how these are structured depends on the type of the geometry) Although the Python Geo Interface is non-prescriptive when it comes to the data types of the id and -properties, Unify has a more restricted set of supported types. See https://docs.tamr.com/reference#attribute-types +properties, Tamr has a more restricted set of supported types. See https://docs.tamr.com/reference#attribute-types The :class:`~tamr_unify_client.models.dataset.resource.Dataset` class supports the ``__geo_interface__`` property. This will produce one ``FeatureCollection`` for the entire dataset. @@ -56,7 +56,7 @@ By default the features' geometries will be placed into the first dataset attrib type. You can override this by specifying the geometry attribute to use in the ``geo_attr`` parameter to ``from_geo_features``. -Rules for converting from Unify records to Geospatial Features +Rules for converting from Tamr records to Geospatial Features ------------------------------------------------------------------ The record's primary key will be used as the feature's ``id``. If the primary key is a single @@ -64,12 +64,12 @@ attribute, then the value of that attribute will be the value of ``id``. If the composed of multiple attributes, then the value of the ``id`` will be an array with the values of the key attributes in order. -Unify allows any number of geometry attributes per record; the Python Geo Interface is limited to -one. When converting Unify records to Python Geo Features, the first geometry attribute in the schema +Tamr allows any number of geometry attributes per record; the Python Geo Interface is limited to +one. When converting Tamr records to Python Geo Features, the first geometry attribute in the schema will be used as the geometry; all other geometry attributes will appear as properties with no type conversion. In the future, additional control over the handling of multiple geometries may be provided; the current set of capabilities is intended primarily to support the use case of working -with FeatureCollections within Unify, and FeatureCollection has only one geometry per feature. +with FeatureCollections within Tamr, and FeatureCollection has only one geometry per feature. An attribute is considered to have geometry type if it has type ``RECORD`` and contains an attribute named ``point``, ``multiPoint``, ``lineString``, ``multiLineString``, ``polygon``, or @@ -82,7 +82,7 @@ may be provided. All other attributes will be placed in ``properties``, with no type conversion. This includes all geometry attributes other than the first. -Rules for converting from Geospatial Features to Unify records +Rules for converting from Geospatial Features to Tamr records -------------------------------------------------------------- The Feature's ``id`` will be converted into the primary key for the record. If the record uses diff --git a/docs/user-guide/quickstart.rst b/docs/user-guide/quickstart.rst index 08cf5925..85a703b3 100644 --- a/docs/user-guide/quickstart.rst +++ b/docs/user-guide/quickstart.rst @@ -25,7 +25,7 @@ Next, create an authentication provider and use that to create an authenticated For more, see `User Guide > Secure Credentials `_ . -By default, the client tries to find the Unify instance on ``localhost``. +By default, the client tries to find the Tamr instance on ``localhost``. To point to a different host, set the host argument when instantiating the Client. For example, to connect to ``10.20.0.1``:: @@ -68,14 +68,14 @@ E.g. To access the Unified Dataset for a particular project:: ud = project.unified_dataset() -Kick-off Unify Operations +Kick-off Tamr Operations ------------------------- -Some methods on Model objects can kick-off long-running Unify operations. +Some methods on Model objects can kick-off long-running Tamr operations. Here, kick-off a "Unified Dataset refresh" operation:: operation = project.unified_dataset().refresh() assert op.succeeded() -By default, the API Clients expose a synchronous interface for Unify operations. +By default, the API Clients expose a synchronous interface for Tamr operations. diff --git a/tamr_unify_client/attribute/resource.py b/tamr_unify_client/attribute/resource.py index 397b7872..335a963b 100644 --- a/tamr_unify_client/attribute/resource.py +++ b/tamr_unify_client/attribute/resource.py @@ -4,7 +4,7 @@ class Attribute(BaseResource): """ - A Unify Attribute. + A Tamr Attribute. See https://docs.tamr.com/reference#attribute-types """ diff --git a/tamr_unify_client/auth/username_password.py b/tamr_unify_client/auth/username_password.py index 1de774f8..2d70bc8e 100644 --- a/tamr_unify_client/auth/username_password.py +++ b/tamr_unify_client/auth/username_password.py @@ -13,8 +13,8 @@ def _basic_auth_str(username, password): class UsernamePasswordAuth(HTTPBasicAuth): - """Provides username/password authentication for Unify. - Specifically, sets the `Authorization` HTTP header with Unify's custom `BasicCreds` format. + """Provides username/password authentication for Tamr. + Specifically, sets the `Authorization` HTTP header with Tamr's custom `BasicCreds` format. :param str username: :param str password: diff --git a/tamr_unify_client/base_model.py b/tamr_unify_client/base_model.py index f5df8966..78b8d70a 100644 --- a/tamr_unify_client/base_model.py +++ b/tamr_unify_client/base_model.py @@ -3,7 +3,7 @@ class MachineLearningModel(BaseResource): - """A Unify Machine Learning model.""" + """A Tamr Machine Learning model.""" @classmethod def from_json(cls, client, resource_json, api_path=None): diff --git a/tamr_unify_client/categorization/project.py b/tamr_unify_client/categorization/project.py index 55f8533b..580eeafe 100644 --- a/tamr_unify_client/categorization/project.py +++ b/tamr_unify_client/categorization/project.py @@ -4,7 +4,7 @@ class CategorizationProject(Project): - """A Categorization project in Unify.""" + """A Categorization project in Tamr.""" def model(self): """Machine learning model for this Categorization project. diff --git a/tamr_unify_client/client.py b/tamr_unify_client/client.py index 7c834f09..3d0d6da6 100644 --- a/tamr_unify_client/client.py +++ b/tamr_unify_client/client.py @@ -24,17 +24,17 @@ def successful(self): class Client: - """Python Client for Unify API. Each client is specific to a specific origin + """Python Client for Tamr API. Each client is specific to a specific origin (protocol, host, port). - :param auth: Unify-compatible Authentication provider. + :param auth: Tamr-compatible Authentication provider. **Recommended**: use one of the classes described in :ref:`authentication` :type auth: :class:`requests.auth.AuthBase` - :param host: Host address of remote Unify instance (e.g. `10.0.10.0`). Default: `'localhost'` + :param host: Host address of remote Tamr instance (e.g. `10.0.10.0`). Default: `'localhost'` :type host: str :param protocol: Either `'http'` or `'https'`. Default: `'http'` :type protocol: str - :param port: Unify instance main port. Default: `9100` + :param port: Tamr instance main port. Default: `9100` :type port: int :param base_path: Base API path. Requests made by this client will be relative to this path. Default: `'api/versioned/v1/'` :type base_path: str @@ -135,7 +135,7 @@ def delete(self, endpoint, **kwargs): @property def projects(self): - """Collection of all projects on this Unify instance. + """Collection of all projects on this Tamr instance. :return: Collection of all projects. :rtype: :class:`~tamr_unify_client.project.collection.ProjectCollection` @@ -144,7 +144,7 @@ def projects(self): @property def datasets(self): - """Collection of all datasets on this Unify instance. + """Collection of all datasets on this Tamr instance. :return: Collection of all datasets. :rtype: :class:`~tamr_unify_client.dataset.collection.DatasetCollection` diff --git a/tamr_unify_client/dataset/collection.py b/tamr_unify_client/dataset/collection.py index 1501aeb4..139baee3 100644 --- a/tamr_unify_client/dataset/collection.py +++ b/tamr_unify_client/dataset/collection.py @@ -81,7 +81,7 @@ def by_name(self, dataset_name): def create(self, creation_spec): """ - Create a Dataset in Unify + Create a Dataset in Tamr :param creation_spec: Dataset creation specification should be formatted as specified in the `Public Docs for Creating a Dataset `_. :type creation_spec: dict[str, str] @@ -107,9 +107,9 @@ def create_from_dataframe( :type df: :class:`pandas.DataFrame` :param primary_key_name: The name of the primary key of the dataset. Must be a column of `df`. :type primary_key_name: str - :param dataset_name: What to name the dataset in Unify. There cannot already be a dataset with this name. + :param dataset_name: What to name the dataset in Tamr. There cannot already be a dataset with this name. :type dataset_name: str - :param ignore_nan: Whether to convert `NaN` values to `null` before upserting records to Unify. If `False` and + :param ignore_nan: Whether to convert `NaN` values to `null` before upserting records to Tamr. If `False` and `NaN` is in `df`, this function will fail. Optional, default is `True`. :type ignore_nan: bool :returns: The newly created dataset. diff --git a/tamr_unify_client/dataset/profile.py b/tamr_unify_client/dataset/profile.py index a8783d84..e53c74b1 100644 --- a/tamr_unify_client/dataset/profile.py +++ b/tamr_unify_client/dataset/profile.py @@ -3,7 +3,7 @@ class DatasetProfile(BaseResource): - """Profile info of a Unify dataset.""" + """Profile info of a Tamr dataset.""" @classmethod def from_json(cls, client, resource_json, api_path=None) -> "DatasetProfile": diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index 084866c0..39ea38e8 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -10,7 +10,7 @@ class Dataset(BaseResource): - """A Unify dataset.""" + """A Tamr dataset.""" @classmethod def from_json(cls, client, resource_json, api_path=None): @@ -64,7 +64,7 @@ def _update_records(self, updates, **json_args): :param records: Each record should be formatted as specified in the `Public Docs for Dataset updates `_. :type records: iterable[dict] :param `**json_args`: Arguments to pass to the JSON `dumps` function, as documented `here `_. - Some of these, such as `indent`, may not work with Unify. + Some of these, such as `indent`, may not work with Tamr. :returns: JSON response body from server. :rtype: :py:class:`dict` """ @@ -90,7 +90,7 @@ def upsert_records(self, records, primary_key_name, **json_args): :param primary_key_name: The name of the primary key for these records, which must be a key in each record dictionary. :type primary_key_name: str :param `**json_args`: Arguments to pass to the JSON `dumps` function, as documented `here `_. - Some of these, such as `indent`, may not work with Unify. + Some of these, such as `indent`, may not work with Tamr. :return: JSON response body from the server. :rtype: dict """ @@ -222,12 +222,12 @@ def from_geo_features(self, features, geo_attr=None): See: geopandas.GeoDataFrame.from_features() - If geo_attr is provided, then the named Unify attribute will be used for the geometry. + If geo_attr is provided, then the named Tamr attribute will be used for the geometry. If geo_attr is not provided, then the first attribute on the dataset with geometry type will be used for the geometry. :param features: geospatial features - :param geo_attr: (optional) name of the Unify attribute to use for the feature's geometry + :param geo_attr: (optional) name of the Tamr attribute to use for the feature's geometry :type geo_attr: str """ if hasattr(features, "__geo_interface__"): @@ -283,7 +283,7 @@ def itergeofeatures(self, geo_attr=None): See https://gist.github.com/sgillies/2217756 - :param geo_attr: (optional) name of the Unify attribute to use for the feature's geometry + :param geo_attr: (optional) name of the Tamr attribute to use for the feature's geometry :type geo_attr: str :return: stream of features :rtype: Python generator yielding :py:class:`dict[str, object]` @@ -330,9 +330,9 @@ def _geo_attr(self): @staticmethod def _record_to_feature(record, key_value, key_attrs, geo_attr): - """Convert a Unify record to a Python Geo Interface Feature + """Convert a Tamr record to a Python Geo Interface Feature - :param record: Unify record + :param record: Tamr record :param key_value: Function to extract the value of the primary key from the record :param key_attrs: Set of attributes that comprise the primary key for the record :param geo_attr: The singular attribute to use as the geometry @@ -364,7 +364,7 @@ def _record_to_feature(record, key_value, key_attrs, geo_attr): @staticmethod def _feature_to_record(feature, key_attrs, geo_attr): - """Convert a Python Geo Interface Feature to a Unify record + """Convert a Python Geo Interface Feature to a Tamr record feature can be a dict representing a Geospatial Feature, or a Feature object that implements the __geo_interface__ property. diff --git a/tamr_unify_client/dataset/status.py b/tamr_unify_client/dataset/status.py index 46741a62..d554983e 100644 --- a/tamr_unify_client/dataset/status.py +++ b/tamr_unify_client/dataset/status.py @@ -2,7 +2,7 @@ class DatasetStatus(BaseResource): - """Streamability status of a Unify dataset.""" + """Streamability status of a Tamr dataset.""" @classmethod def from_json(cls, client, resource_json, api_path=None) -> "DatasetStatus": diff --git a/tamr_unify_client/dataset/uri.py b/tamr_unify_client/dataset/uri.py index 3453aaeb..56397b33 100644 --- a/tamr_unify_client/dataset/uri.py +++ b/tamr_unify_client/dataset/uri.py @@ -30,7 +30,7 @@ def uri(self): def dataset(self): """Fetch the dataset that this identifier points to. - :return: A Unify dataset. + :return: A Tamr dataset. :rtype: :class: `~tamr_unify_client.dataset.resource.Dataset` """ return self.client.datasets.by_resource_id(self.resource_id) diff --git a/tamr_unify_client/mastering/project.py b/tamr_unify_client/mastering/project.py index e94a045d..524898a4 100644 --- a/tamr_unify_client/mastering/project.py +++ b/tamr_unify_client/mastering/project.py @@ -13,11 +13,11 @@ class MasteringProject(Project): - """A Mastering project in Unify.""" + """A Mastering project in Tamr.""" def pairs(self): - """Record pairs generated by Unify's binning model. - Pairs are displayed on the "Pairs" page in the Unify UI. + """Record pairs generated by Tamr's binning model. + Pairs are displayed on the "Pairs" page in the Tamr UI. Call :func:`~tamr_unify_client.dataset.resource.Dataset.refresh` from this dataset to regenerate pairs according to the latest binning model. @@ -34,7 +34,7 @@ def pair_matching_model(self): Calling :func:`~tamr_unify_client.base_model.MachineLearningModel.predict` from this dataset will produce new (unpublished) clusters. These clusters - are displayed on the "Clusters" page in the Unify UI. + are displayed on the "Clusters" page in the Tamr UI. :returns: The machine learning model for pair-matching. :rtype: :class:`~tamr_unify_client.base_model.MachineLearningModel` @@ -43,11 +43,11 @@ def pair_matching_model(self): return MachineLearningModel(self.client, None, alias) def high_impact_pairs(self): - """High-impact pairs as a dataset. Unify labels pairs as "high-impact" if + """High-impact pairs as a dataset. Tamr labels pairs as "high-impact" if labeling these pairs would help it learn most quickly (i.e. "Active learning"). High-impact pairs are displayed with a ⚡ lightning bolt icon on the - "Pairs" page in the Unify UI. + "Pairs" page in the Tamr UI. Call :func:`~tamr_unify_client.dataset.resource.Dataset.refresh` from this dataset to produce new high-impact pairs according to the latest @@ -60,7 +60,7 @@ def high_impact_pairs(self): return Dataset(self.client, None, alias) def record_clusters(self): - """Record Clusters as a dataset. Unify clusters labeled pairs using pairs + """Record Clusters as a dataset. Tamr clusters labeled pairs using pairs model. These clusters populate the cluster review page and get transient cluster ids, rather than published cluster ids (i.e., "Permanent Ids") @@ -74,7 +74,7 @@ def record_clusters(self): return Dataset(self.client, None, alias) def published_clusters(self): - """Published record clusters generated by Unify's pair-matching model. + """Published record clusters generated by Tamr's pair-matching model. :returns: The published clusters represented as a dataset. :rtype: :class:`~tamr_unify_client.dataset.resource.Dataset` diff --git a/tamr_unify_client/operation.py b/tamr_unify_client/operation.py index 2c3a2de4..7a984b07 100644 --- a/tamr_unify_client/operation.py +++ b/tamr_unify_client/operation.py @@ -4,8 +4,8 @@ class Operation(BaseResource): - """A long-running operation performed by Unify. - Operations appear on the "Jobs" page of the Unify UI. + """A long-running operation performed by Tamr. + Operations appear on the "Jobs" page of the Tamr UI. By design, client-side operations represent server-side operations *at a particular point in time* (namely, when the operation was fetched from the diff --git a/tamr_unify_client/project/attribute_configuration/resource.py b/tamr_unify_client/project/attribute_configuration/resource.py index 8aa774b5..be1b7ee2 100644 --- a/tamr_unify_client/project/attribute_configuration/resource.py +++ b/tamr_unify_client/project/attribute_configuration/resource.py @@ -2,7 +2,7 @@ class AttributeConfiguration(BaseResource): - """The configurations of Unify Attributes. + """The configurations of Tamr Attributes. See https://docs.tamr.com/reference#the-attribute-configuration-object """ diff --git a/tamr_unify_client/project/collection.py b/tamr_unify_client/project/collection.py index 765aae73..e701debb 100644 --- a/tamr_unify_client/project/collection.py +++ b/tamr_unify_client/project/collection.py @@ -64,7 +64,7 @@ def stream(self): def create(self, creation_spec): """ - Create a Project in Unify + Create a Project in Tamr :param creation_spec: Project creation specification should be formatted as specified in the `Public Docs for Creating a Project `_. :type creation_spec: dict[str, str] diff --git a/tamr_unify_client/project/resource.py b/tamr_unify_client/project/resource.py index b5aab66d..3a670794 100644 --- a/tamr_unify_client/project/resource.py +++ b/tamr_unify_client/project/resource.py @@ -10,7 +10,7 @@ class Project(BaseResource): - """A Unify project.""" + """A Tamr project.""" @classmethod def from_json(cls, client, resource_json, api_path=None): @@ -33,7 +33,7 @@ def description(self): @property def type(self): - """A Unify project type, listed in https://docs.tamr.com/reference#create-a-project. + """A Tamr project type, listed in https://docs.tamr.com/reference#create-a-project. :type: str """ @@ -93,7 +93,7 @@ def as_mastering(self): def add_input_dataset(self, dataset): """ - Associate a dataset with a project in Unify. + Associate a dataset with a project in Tamr. By default, datasets are not associated with any projects. They need to be added as input to a project before they can be used diff --git a/tamr_unify_client/project/step.py b/tamr_unify_client/project/step.py index 52936685..3f9b8f84 100644 --- a/tamr_unify_client/project/step.py +++ b/tamr_unify_client/project/step.py @@ -1,5 +1,5 @@ class ProjectStep: - """A step of a Unify project. This is not a `BaseResource` because it has no API path + """A step of a Tamr project. This is not a `BaseResource` because it has no API path and cannot be directly retrieved or modified. See https://docs.tamr.com/reference#retrieve-downstream-dataset-usage @@ -31,7 +31,7 @@ def project_name(self): @property def type(self): - """A Unify project type, listed in https://docs.tamr.com/reference#create-a-project. + """A Tamr project type, listed in https://docs.tamr.com/reference#create-a-project. :type: str""" return self._data.get("type") From c536ba8587b6ae9347fa1035ea74e80bb307417c Mon Sep 17 00:00:00 2001 From: Charlotte Moremen Date: Tue, 13 Aug 2019 11:07:51 -0400 Subject: [PATCH 140/632] updated w master brancg --- tamr_unify_client/project/resource.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tamr_unify_client/project/resource.py b/tamr_unify_client/project/resource.py index b5aab66d..bbb70329 100644 --- a/tamr_unify_client/project/resource.py +++ b/tamr_unify_client/project/resource.py @@ -104,9 +104,9 @@ def add_input_dataset(self, dataset): :return: HTTP response from the server :rtype: :class:`requests.Response` """ - dataset_id = dataset.relative_id.split("/")[-1] + params = {"id": dataset.relative_id} response = self.client.post( - self.api_path + "/inputDatasets" + f"?id={dataset_id}" + self.api_path + "/inputDatasets", params=params ).successful() return response From 209ca10a50ecfc7314745faf0173ef6fc400c7bf Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 13 Aug 2019 13:49:01 -0400 Subject: [PATCH 141/632] remove unify from tests --- tests/unit/test_attribute.py | 16 +++++++------- tests/unit/test_attribute_configuration.py | 10 ++++----- ...test_attribute_configuration_collection.py | 10 ++++----- .../unit/test_attribute_mapping_collection.py | 8 +++---- tests/unit/test_base_path.py | 20 ++++++++--------- tests/unit/test_binning_model.py | 8 +++---- tests/unit/test_categorization.py | 4 ++-- tests/unit/test_category.py | 10 ++++----- tests/unit/test_create_project.py | 6 ++--- tests/unit/test_dataset.py | 6 ++--- tests/unit/test_dataset_attributes.py | 4 ++-- tests/unit/test_dataset_by_external_id.py | 8 +++---- tests/unit/test_dataset_geo.py | 14 ++++++------ tests/unit/test_dataset_records.py | 16 +++++++------- tests/unit/test_dataset_status.py | 4 ++-- tests/unit/test_dataset_usage.py | 14 ++++++------ tests/unit/test_http_error.py | 4 ++-- tests/unit/test_pair_counts.py | 12 +++++----- tests/unit/test_project.py | 22 +++++++++---------- tests/unit/test_published_cluster_version.py | 6 ++--- tests/unit/test_published_clusters.py | 14 ++++++------ .../unit/test_published_clusters_with_data.py | 4 ++-- tests/unit/test_record_clusters_with_data.py | 4 ++-- tests/unit/test_strings.py | 8 +++---- tests/unit/test_taxonomy.py | 18 +++++++-------- tests/unit/test_upstream_dataset.py | 4 ++-- 26 files changed, 127 insertions(+), 127 deletions(-) diff --git a/tests/unit/test_attribute.py b/tests/unit/test_attribute.py index d4045473..6b001f49 100644 --- a/tests/unit/test_attribute.py +++ b/tests/unit/test_attribute.py @@ -12,11 +12,11 @@ class TestAttribute(TestCase): def setUp(self): auth = UsernamePasswordAuth("username", "password") - self.unify = Client(auth) + self.tamr = Client(auth) def test_resource(self): alias = "datasets/1/attributes/RowNum" - row_num = Attribute(self.unify, self._attributes_json[0], alias) + row_num = Attribute(self.tamr, self._attributes_json[0], alias) expected = alias self.assertEqual(expected, row_num.relative_id) @@ -32,13 +32,13 @@ def test_resource(self): def test_resource_from_json(self): alias = "datasets/1/attributes/RowNum" - expected = Attribute(self.unify, self._attributes_json[0], alias) - actual = Attribute.from_json(self.unify, self._attributes_json[0], alias) + expected = Attribute(self.tamr, self._attributes_json[0], alias) + actual = Attribute.from_json(self.tamr, self._attributes_json[0], alias) self.assertEqual(repr(expected), repr(actual)) def test_simple_type(self): alias = "datasets/1/attributes/RowNum" - row_num = Attribute(self.unify, self._attributes_json[0], alias) + row_num = Attribute(self.tamr, self._attributes_json[0], alias) row_num_type = row_num.type expected = self._attributes_json[0]["type"]["baseType"] self.assertEqual(expected, row_num_type.base_type) @@ -47,7 +47,7 @@ def test_simple_type(self): def test_complex_type(self): alias = "datasets/1/attributes/geom" - geom = Attribute(self.unify, self._attributes_json[1], alias) + geom = Attribute(self.tamr, self._attributes_json[1], alias) self.assertEqual("RECORD", geom.type.base_type) self.assertIsNone(geom.type.inner_type) self.assertEqual(3, len(list(geom.type.attributes))) @@ -65,7 +65,7 @@ def test_dataset_attributes(self): attributes_url = f"http://localhost:9100/api/versioned/v1/datasets/1/attributes" responses.add(responses.GET, dataset_url, json=self._dataset_json) responses.add(responses.GET, attributes_url, json=self._attributes_json) - dataset = self.unify.datasets.by_resource_id("1") + dataset = self.tamr.datasets.by_resource_id("1") self.assertSequenceEqual( self._dataset_json["keyAttributeNames"], dataset.key_attribute_names ) @@ -81,7 +81,7 @@ def test_delete_attribute(self): responses.add(responses.DELETE, url, status=204) responses.add(responses.GET, url, status=404) - dataset = Dataset(self.unify, self._dataset_json) + dataset = Dataset(self.tamr, self._dataset_json) attribute = dataset.attributes.by_resource_id("RowNum") self.assertEqual(attribute._data, self._attributes_json[0]) diff --git a/tests/unit/test_attribute_configuration.py b/tests/unit/test_attribute_configuration.py index bceed9ad..5bd76f4a 100644 --- a/tests/unit/test_attribute_configuration.py +++ b/tests/unit/test_attribute_configuration.py @@ -16,11 +16,11 @@ class TestAttributeConfiguration(TestCase): def setUp(self): auth = UsernamePasswordAuth("username", "password") - self.unify = Client(auth) + self.tamr = Client(auth) def test_resource(self): alias = "projects/1/attributeConfigurations/26" - test = AttributeConfiguration(self.unify, self._ac_json, alias) + test = AttributeConfiguration(self.tamr, self._ac_json, alias) expected = alias self.assertEqual(expected, test.relative_id) @@ -51,8 +51,8 @@ def test_resource(self): def test_resource_from_json(self): alias = "projects/1/attributeConfigurations/26" - expected = AttributeConfiguration(self.unify, self._ac_json, alias) - actual = AttributeConfiguration.from_json(self.unify, self._ac_json, alias) + expected = AttributeConfiguration(self.tamr, self._ac_json, alias) + actual = AttributeConfiguration.from_json(self.tamr, self._ac_json, alias) self.assertEqual(repr(expected), repr(actual)) @responses.activate @@ -66,7 +66,7 @@ def test_delete(self): responses.add(responses.DELETE, url, status=204) responses.add(responses.GET, url, status=404) - collection = AttributeConfigurationCollection(self.unify, alias) + collection = AttributeConfigurationCollection(self.tamr, alias) config = collection.by_resource_id(attribute_id) self.assertEqual(config._data, self._ac_json) diff --git a/tests/unit/test_attribute_configuration_collection.py b/tests/unit/test_attribute_configuration_collection.py index 99c1d66b..c7e306b4 100644 --- a/tests/unit/test_attribute_configuration_collection.py +++ b/tests/unit/test_attribute_configuration_collection.py @@ -12,13 +12,13 @@ class TestAttributeConfigurationCollection(TestCase): def setUp(self): auth = UsernamePasswordAuth("username", "password") - self.unify = Client(auth) + self.tamr = Client(auth) @responses.activate def test_by_relative_id(self): ac_url = f"http://localhost:9100/api/versioned/v1/projects/1/attributeConfigurations/1" alias = "projects/1/attributeConfigurations/" - ac_test = AttributeConfigurationCollection(self.unify, alias) + ac_test = AttributeConfigurationCollection(self.tamr, alias) expected = self.acc_json[0]["relativeId"] responses.add(responses.GET, ac_url, json=self.acc_json[0]) self.assertEqual( @@ -30,7 +30,7 @@ def test_by_relative_id(self): def test_by_resource_id(self): ac_url = f"http://localhost:9100/api/versioned/v1/projects/1/attributeConfigurations/1" alias = "projects/1/attributeConfigurations/" - ac_test = AttributeConfigurationCollection(self.unify, alias) + ac_test = AttributeConfigurationCollection(self.tamr, alias) expected = self.acc_json[0]["relativeId"] responses.add(responses.GET, ac_url, json=self.acc_json[0]) self.assertEqual(expected, ac_test.by_resource_id("1").relative_id) @@ -45,7 +45,7 @@ def test_create(self): responses.add(responses.POST, url, json=self.create_json, status=204) responses.add(responses.GET, url, json=self.create_json) - attributeconfig = self.unify.projects.by_resource_id( + attributeconfig = self.tamr.projects.by_resource_id( "1" ).attribute_configurations() create = attributeconfig.create(self.create_json) @@ -56,7 +56,7 @@ def test_create(self): def test_stream(self): ac_url = f"http://localhost:9100/api/versioned/v1/projects/1/attributeConfigurations/" alias = "projects/1/attributeConfigurations/" - ac_test = AttributeConfigurationCollection(self.unify, alias) + ac_test = AttributeConfigurationCollection(self.tamr, alias) responses.add(responses.GET, ac_url, json=self.acc_json) streamer = ac_test.stream() stream_content = [] diff --git a/tests/unit/test_attribute_mapping_collection.py b/tests/unit/test_attribute_mapping_collection.py index d9668980..5911cd94 100644 --- a/tests/unit/test_attribute_mapping_collection.py +++ b/tests/unit/test_attribute_mapping_collection.py @@ -14,13 +14,13 @@ class TestAttributeMappingCollection(TestCase): def setUp(self): auth = UsernamePasswordAuth("username", "password") - self.unify = Client(auth) + self.tamr = Client(auth) @responses.activate def test_by_resource_id(self): url = "http://localhost:9100/api/versioned/v1/projects/4/attributeMappings" responses.add(responses.GET, url, json=self.mappings_json) - tester = AttributeMappingCollection(self.unify, url) + tester = AttributeMappingCollection(self.tamr, url) by_resource = tester.by_resource_id("19629-12") self.assertEqual( by_resource.unified_attribute_name, @@ -31,7 +31,7 @@ def test_by_resource_id(self): def test_by_relative_id(self): url = "http://localhost:9100/api/versioned/v1/projects/4/attributeMappings" responses.add(responses.GET, url, json=self.mappings_json) - tester = AttributeMappingCollection(self.unify, url) + tester = AttributeMappingCollection(self.tamr, url) by_relative = tester.by_relative_id("projects/4/attributeMappings/19629-12") self.assertEqual( by_relative.unified_attribute_name, @@ -51,7 +51,7 @@ def create_callback(request, snoop): responses.POST, url, partial(create_callback, snoop=snoop_dict) ) map_collection = AttributeMappingCollection( - self.unify, "projects/4/attributeMappings" + self.tamr, "projects/4/attributeMappings" ) test = map_collection.create(self.create_json) self.assertEqual(test.input_dataset_name, self.create_json["inputDatasetName"]) diff --git a/tests/unit/test_base_path.py b/tests/unit/test_base_path.py index 1498c20a..b7355308 100644 --- a/tests/unit/test_base_path.py +++ b/tests/unit/test_base_path.py @@ -17,45 +17,45 @@ @responses.activate def test_base_path_no_trailing_slash(): bad_base_path = "/api/versioned/v1" - unify = Client(auth, base_path=bad_base_path) + tamr = Client(auth, base_path=bad_base_path) full_url = "http://localhost:9100/api/versioned/v1/datasets/1" responses.add(responses.GET, full_url, status=200) - unify.get("datasets/1") + tamr.get("datasets/1") @responses.activate def test_base_path_no_leading_slash(): bad_base_path = "api/versioned/v1/" - unify = Client(auth, base_path=bad_base_path) + tamr = Client(auth, base_path=bad_base_path) full_url = "http://localhost:9100/api/versioned/v1/datasets/1" responses.add(responses.GET, full_url, status=200) - unify.get("datasets/1") + tamr.get("datasets/1") @responses.activate def test_base_path_no_slash(): bad_base_path = "api/versioned/v1" - unify = Client(auth, base_path=bad_base_path) + tamr = Client(auth, base_path=bad_base_path) full_url = "http://localhost:9100/api/versioned/v1/datasets/1" responses.add(responses.GET, full_url, status=200) - unify.get("datasets/1") + tamr.get("datasets/1") @responses.activate def test_base_path_default_slash(): standard_base_path = "/api/versioned/v1/" - unify = Client(auth, base_path=standard_base_path) + tamr = Client(auth, base_path=standard_base_path) full_url = "http://localhost:9100/api/versioned/v1/datasets/1" responses.add(responses.GET, full_url, status=200) - unify.get("datasets/1") + tamr.get("datasets/1") @responses.activate def test_base_path_no_base_path(): - unify = Client(auth) + tamr = Client(auth) full_url = "http://localhost:9100/api/versioned/v1/datasets/2" responses.add(responses.GET, full_url, status=400) - unify.get("datasets/2") + tamr.get("datasets/2") @responses.activate diff --git a/tests/unit/test_binning_model.py b/tests/unit/test_binning_model.py index e1ab0efc..7fe111c2 100644 --- a/tests/unit/test_binning_model.py +++ b/tests/unit/test_binning_model.py @@ -45,9 +45,9 @@ def test_binning_model_records(): body="\n".join(json.dumps(body) for body in records_body), ) - unify = Client(UsernamePasswordAuth("username", "password")) + tamr = Client(UsernamePasswordAuth("username", "password")) - project = unify.projects.by_resource_id("1").as_mastering() + project = tamr.projects.by_resource_id("1").as_mastering() binning_model = project.binning_model() binning_model_records = list(binning_model.records()) @@ -147,9 +147,9 @@ def update_callback(request, snoop): callback=partial(update_callback, snoop=snoop_dict), ) - unify = Client(UsernamePasswordAuth("username", "password")) + tamr = Client(UsernamePasswordAuth("username", "password")) - project = unify.projects.by_resource_id("1").as_mastering() + project = tamr.projects.by_resource_id("1").as_mastering() binning_model = project.binning_model() updates = [ diff --git a/tests/unit/test_categorization.py b/tests/unit/test_categorization.py index 0bcb4ba6..6c60ef03 100644 --- a/tests/unit/test_categorization.py +++ b/tests/unit/test_categorization.py @@ -9,7 +9,7 @@ class TestCategorization(TestCase): def setUp(self): auth = UsernamePasswordAuth("username", "password") - self.unify = Client(auth) + self.tamr = Client(auth) @responses.activate def test_taxonomy(self): @@ -18,7 +18,7 @@ def test_taxonomy(self): responses.add(responses.GET, project_url, json=self._project_json) responses.add(responses.POST, taxonomy_url, json=self._taxonomy_json) - project = self.unify.projects.by_resource_id("1").as_categorization() + project = self.tamr.projects.by_resource_id("1").as_categorization() creation_spec = {"name": "Test Taxonomy"} u = project.create_taxonomy(creation_spec) diff --git a/tests/unit/test_category.py b/tests/unit/test_category.py index 76a6884e..5c77452c 100644 --- a/tests/unit/test_category.py +++ b/tests/unit/test_category.py @@ -10,11 +10,11 @@ class TestCategory(TestCase): def setUp(self): auth = UsernamePasswordAuth("username", "password") - self.unify = Client(auth) + self.tamr = Client(auth) def test_resource(self): alias = "projects/1/taxonomy/categories/1" - row_num = Category(self.unify, self._categories_json[0], alias) + row_num = Category(self.tamr, self._categories_json[0], alias) expected = alias self.assertEqual(expected, row_num.relative_id) @@ -27,14 +27,14 @@ def test_resource(self): def test_resource_from_json(self): alias = "projects/1/taxonomy/categories/1" - expected = Category(self.unify, self._categories_json[0], alias) - actual = Category.from_json(self.unify, self._categories_json[0], alias) + expected = Category(self.tamr, self._categories_json[0], alias) + actual = Category.from_json(self.tamr, self._categories_json[0], alias) self.assertEqual(repr(expected), repr(actual)) @responses.activate def test_path(self): t2 = Category( - self.unify, self._categories_json[1], "projects/1/taxonomy/categories/2" + self.tamr, self._categories_json[1], "projects/1/taxonomy/categories/2" ) parent_url = ( diff --git a/tests/unit/test_create_project.py b/tests/unit/test_create_project.py index 7208e898..7b31df1e 100644 --- a/tests/unit/test_create_project.py +++ b/tests/unit/test_create_project.py @@ -6,7 +6,7 @@ from tamr_unify_client.auth import UsernamePasswordAuth auth = UsernamePasswordAuth("username", "password") -unify = Client(auth) +tamr = Client(auth) @responses.activate @@ -26,6 +26,6 @@ def test_create_project(): responses.add(responses.POST, projects_url, json=creation_spec, status=204) responses.add(responses.GET, project_url, json=creation_spec) - u = unify.projects.create(creation_spec) - p = unify.projects.by_resource_id("1") + u = tamr.projects.create(creation_spec) + p = tamr.projects.by_resource_id("1") assert print(p) == print(u) diff --git a/tests/unit/test_dataset.py b/tests/unit/test_dataset.py index 8f02c809..bf454288 100644 --- a/tests/unit/test_dataset.py +++ b/tests/unit/test_dataset.py @@ -10,7 +10,7 @@ class TestAttribute(TestCase): def setUp(self): auth = UsernamePasswordAuth("username", "password") - self.unify = Client(auth) + self.tamr = Client(auth) @responses.activate def test_delete(self): @@ -19,12 +19,12 @@ def test_delete(self): responses.add(responses.DELETE, url, status=204) responses.add(responses.GET, url, status=404) - dataset = self.unify.datasets.by_resource_id("1") + dataset = self.tamr.datasets.by_resource_id("1") self.assertEqual(dataset._data, self._dataset_json) response = dataset.delete() self.assertEqual(response.status_code, 204) - self.assertRaises(HTTPError, lambda: self.unify.datasets.by_resource_id("1")) + self.assertRaises(HTTPError, lambda: self.tamr.datasets.by_resource_id("1")) _dataset_json = { "id": "unify://unified-data/v1/datasets/1", diff --git a/tests/unit/test_dataset_attributes.py b/tests/unit/test_dataset_attributes.py index 9c545926..7da576f3 100644 --- a/tests/unit/test_dataset_attributes.py +++ b/tests/unit/test_dataset_attributes.py @@ -4,7 +4,7 @@ from tamr_unify_client.auth import UsernamePasswordAuth auth = UsernamePasswordAuth("username", "password") -unify = Client(auth) +tamr = Client(auth) @responses.activate @@ -31,7 +31,7 @@ def test_dataset_attributes(): json=attribute_creation_spec, ) - dataset = unify.datasets.by_resource_id("1") + dataset = tamr.datasets.by_resource_id("1") create = dataset.attributes.create(attribute_creation_spec) created = dataset.attributes.by_name("myAttribute") diff --git a/tests/unit/test_dataset_by_external_id.py b/tests/unit/test_dataset_by_external_id.py index 42efdc4e..c68489e0 100644 --- a/tests/unit/test_dataset_by_external_id.py +++ b/tests/unit/test_dataset_by_external_id.py @@ -38,15 +38,15 @@ def test_dataset_by_external_id__raises_when_not_found(): responses.add(responses.GET, datasets_url, json=[]) auth = UsernamePasswordAuth("username", "password") - unify = Client(auth) + tamr = Client(auth) with pytest.raises(KeyError): - unify.datasets.by_external_id(dataset_external_id) + tamr.datasets.by_external_id(dataset_external_id) @responses.activate def test_dataset_by_external_id_succeeds(): responses.add(responses.GET, datasets_url, json=dataset_json) auth = UsernamePasswordAuth("username", "password") - unify = Client(auth) - actual_dataset = unify.datasets.by_external_id(dataset_external_id) + tamr = Client(auth) + actual_dataset = tamr.datasets.by_external_id(dataset_external_id) assert actual_dataset._data == dataset_json[0] diff --git a/tests/unit/test_dataset_geo.py b/tests/unit/test_dataset_geo.py index 16199f24..8a9b2177 100644 --- a/tests/unit/test_dataset_geo.py +++ b/tests/unit/test_dataset_geo.py @@ -14,7 +14,7 @@ class TestDatasetGeo(TestCase): def setUp(self): auth = UsernamePasswordAuth("username", "password") - self.unify = Client(auth) + self.tamr = Client(auth) def test_record_to_feature(self): empty_record = {"id": "1"} @@ -243,7 +243,7 @@ def test_geo_features(self): records_url, body="\n".join([json.dumps(rec) for rec in self._records_json]), ) - dataset = self.unify.datasets.by_resource_id("1") + dataset = self.tamr.datasets.by_resource_id("1") features = [feature for feature in dataset.itergeofeatures()] self.assertEqual(6, len(features)) self.assertSetEqual( @@ -275,7 +275,7 @@ def test_geo_features_geo_attr(self): record = {"id": "point", "geom": {"point": [1, 1]}, "geom2": {"point": [2, 2]}} records_url = f"{dataset_url}/records" responses.add(responses.GET, records_url, body=json.dumps(record)) - dataset = self.unify.datasets.by_resource_id("1") + dataset = self.tamr.datasets.by_resource_id("1") # Default is to get the first attribute with geometry type feature = next(dataset.itergeofeatures()) @@ -299,7 +299,7 @@ def test_geo_interface(self): records_url, body="\n".join([json.dumps(rec) for rec in self._records_json]), ) - dataset = self.unify.datasets.by_resource_id("1") + dataset = self.tamr.datasets.by_resource_id("1") fc = dataset.__geo_interface__ self.assertEqual("FeatureCollection", fc["type"]) self.assertSetEqual( @@ -507,7 +507,7 @@ def update_callback(request, snoop): responses.POST, records_url, callback=partial(update_callback, snoop=snoop) ) - dataset = self.unify.datasets.by_resource_id("1") + dataset = self.tamr.datasets.by_resource_id("1") features = [ {"id": "1", "geometry": {"type": "Point", "coordinates": [0, 0]}}, {"id": "2", "geometry": {"type": "Point", "coordinates": [1, 1]}}, @@ -563,7 +563,7 @@ def update_callback(request, snoop): responses.POST, records_url, callback=partial(update_callback, snoop=snoop) ) - dataset = self.unify.datasets.by_resource_id("1") + dataset = self.tamr.datasets.by_resource_id("1") features = [{"id": "1", "geometry": {"type": "Point", "coordinates": [0, 0]}}] # by default, the first attribute with geometry type is used for geometry @@ -617,7 +617,7 @@ def update_callback(request, snoop): responses.POST, records_url, callback=partial(update_callback, snoop=snoop) ) - dataset = self.unify.datasets.by_resource_id("1") + dataset = self.tamr.datasets.by_resource_id("1") features = [ {"id": ["1", "a"], "geometry": {"type": "Point", "coordinates": [0, 0]}}, {"id": ["2", "b"], "geometry": {"type": "Point", "coordinates": [1, 1]}}, diff --git a/tests/unit/test_dataset_records.py b/tests/unit/test_dataset_records.py index d96e9533..2a886bf8 100644 --- a/tests/unit/test_dataset_records.py +++ b/tests/unit/test_dataset_records.py @@ -12,7 +12,7 @@ class TestDatasetRecords(TestCase): def setUp(self): auth = UsernamePasswordAuth("username", "password") - self.unify = Client(auth) + self.tamr = Client(auth) @responses.activate def test_get(self): @@ -24,7 +24,7 @@ def test_get(self): body="\n".join([simplejson.dumps(l) for l in self._records_json]), ) - dataset = self.unify.datasets.by_resource_id(self._dataset_id) + dataset = self.tamr.datasets.by_resource_id(self._dataset_id) records = list(dataset.records()) self.assertListEqual(records, self._records_json) @@ -35,7 +35,7 @@ def create_callback(request, snoop): return 200, {}, simplejson.dumps(self._response_json) responses.add(responses.GET, self._dataset_url, json={}) - dataset = self.unify.datasets.by_resource_id(self._dataset_id) + dataset = self.tamr.datasets.by_resource_id(self._dataset_id) records_url = f"{self._dataset_url}:updateRecords" updates = TestDatasetRecords.records_to_updates(self._records_json) @@ -55,7 +55,7 @@ def create_callback(request, snoop, status): return status, {}, simplejson.dumps(self._response_json) responses.add(responses.GET, self._dataset_url, json={}) - dataset = self.unify.datasets.by_resource_id(self._dataset_id) + dataset = self.tamr.datasets.by_resource_id(self._dataset_id) records_url = f"{self._dataset_url}:updateRecords" updates = TestDatasetRecords.records_to_updates(self._nan_records_json) @@ -86,7 +86,7 @@ def create_callback(request, snoop): return 200, {}, simplejson.dumps(self._response_json) responses.add(responses.GET, self._dataset_url, json={}) - dataset = self.unify.datasets.by_resource_id(self._dataset_id) + dataset = self.tamr.datasets.by_resource_id(self._dataset_id) records_url = f"{self._dataset_url}:updateRecords" updates = TestDatasetRecords.records_to_updates(self._records_json) @@ -106,7 +106,7 @@ def create_callback(request, snoop): return 200, {}, simplejson.dumps(self._response_json) responses.add(responses.GET, self._dataset_url, json={}) - dataset = self.unify.datasets.by_resource_id(self._dataset_id) + dataset = self.tamr.datasets.by_resource_id(self._dataset_id) records_url = f"{self._dataset_url}:updateRecords" deletes = TestDatasetRecords.records_to_deletes(self._records_json) @@ -126,7 +126,7 @@ def create_callback(request, snoop): return 200, {}, simplejson.dumps(self._response_json) responses.add(responses.GET, self._dataset_url, json={}) - dataset = self.unify.datasets.by_resource_id(self._dataset_id) + dataset = self.tamr.datasets.by_resource_id(self._dataset_id) records_url = f"{self._dataset_url}:updateRecords" deletes = TestDatasetRecords.records_to_deletes(self._records_json) @@ -143,7 +143,7 @@ def create_callback(request, snoop): @responses.activate def test_delete_all(self): responses.add(responses.GET, self._dataset_url, json={}) - dataset = self.unify.datasets.by_resource_id(self._dataset_id) + dataset = self.tamr.datasets.by_resource_id(self._dataset_id) responses.add(responses.DELETE, self._dataset_url + "/records", status=204) response = dataset.delete_all_records() diff --git a/tests/unit/test_dataset_status.py b/tests/unit/test_dataset_status.py index e31a04c4..6c769dd2 100644 --- a/tests/unit/test_dataset_status.py +++ b/tests/unit/test_dataset_status.py @@ -20,8 +20,8 @@ def test_dataset_status(): responses.add(responses.GET, dataset_url, json={}) responses.add(responses.GET, status_url, json=status_json) auth = UsernamePasswordAuth("username", "password") - unify = Client(auth) + tamr = Client(auth) - dataset = unify.datasets.by_resource_id(dataset_id) + dataset = tamr.datasets.by_resource_id(dataset_id) status = dataset.status() assert status._data == status_json diff --git a/tests/unit/test_dataset_usage.py b/tests/unit/test_dataset_usage.py index 63622a33..e89b4466 100644 --- a/tests/unit/test_dataset_usage.py +++ b/tests/unit/test_dataset_usage.py @@ -13,31 +13,31 @@ class TestUsage(TestCase): def setUp(self): auth = UsernamePasswordAuth("username", "password") - self.unify = Client(auth) + self.tamr = Client(auth) @responses.activate def test_get_usage(self): responses.add( responses.GET, f"{self._base_url}/datasets/1/usage", json=self._usage_json ) - u = Dataset(self.unify, self._dataset_json).usage() + u = Dataset(self.tamr, self._dataset_json).usage() self.assertEqual(u._data, self._usage_json) def test_usage(self): alias = "datasets/1/usage" - u = DatasetUsage(self.unify, self._usage_json, alias) + u = DatasetUsage(self.tamr, self._usage_json, alias) self.assertEqual(u.usage._data, self._usage_json["usage"]) self.assertEqual(u.relative_id, alias) udeps = u.dependencies - deps = [DatasetUse(self.unify, dep) for dep in self._usage_json["dependencies"]] + deps = [DatasetUse(self.tamr, dep) for dep in self._usage_json["dependencies"]] for i in range(len(deps)): self.assertEqual(deps[i].dataset_id, udeps[i].dataset_id) @responses.activate def test_use(self): usage_json = self._usage_json["usage"] - u = DatasetUse(self.unify, usage_json) + u = DatasetUse(self.tamr, usage_json) responses.add( responses.GET, f"{self._base_url}/datasets/1", json=self._dataset_json @@ -48,7 +48,7 @@ def test_use(self): self.assertEqual(u.output_from_project_steps, []) inputs = u.input_to_project_steps - step = ProjectStep(self.unify, usage_json["inputToProjectSteps"][0]) + step = ProjectStep(self.tamr, usage_json["inputToProjectSteps"][0]) self.assertEqual(len(inputs), 1) self.assertEqual(repr(inputs[0]), repr(step)) @@ -58,7 +58,7 @@ def test_use(self): @responses.activate def test_project_step(self): step_json = self._usage_json["usage"]["inputToProjectSteps"][0] - step = ProjectStep(self.unify, step_json) + step = ProjectStep(self.tamr, step_json) self.assertEqual(step.project_step_id, step_json["projectStepId"]) self.assertEqual(step.project_step_name, step_json["projectStepName"]) diff --git a/tests/unit/test_http_error.py b/tests/unit/test_http_error.py index b1a20246..5ed13b7a 100644 --- a/tests/unit/test_http_error.py +++ b/tests/unit/test_http_error.py @@ -13,7 +13,7 @@ def test_http_error(): endpoint = f"http://localhost:9100/api/versioned/v1/projects/1" responses.add(responses.GET, endpoint, status=401) auth = UsernamePasswordAuth("nonexistent-username", "invalid-password") - unify = Client(auth) + tamr = Client(auth) with raises(HTTPError) as e: - unify.projects.by_resource_id("1") + tamr.projects.by_resource_id("1") assert f"401 Client Error: Unauthorized for url: {endpoint}" in str(e) diff --git a/tests/unit/test_pair_counts.py b/tests/unit/test_pair_counts.py index 68e469c0..7dc7c652 100644 --- a/tests/unit/test_pair_counts.py +++ b/tests/unit/test_pair_counts.py @@ -12,11 +12,11 @@ class TestPairCounts(TestCase): def setUp(self): auth = UsernamePasswordAuth("username", "password") - self.unify = Client(auth) + self.tamr = Client(auth) @responses.activate def test_get(self): - p = MasteringProject(self.unify, self._project_json) + p = MasteringProject(self.tamr, self._project_json) responses.add( responses.GET, f"{self._url_base}/{self._api_path}", @@ -25,13 +25,13 @@ def test_get(self): generated = p.estimate_pairs() created = EstimatedPairCounts.from_json( - self.unify, self._estimate_json, self._api_path + self.tamr, self._estimate_json, self._api_path ) self.assertEqual(repr(generated), repr(created)) def test_properties(self): estimate = EstimatedPairCounts.from_json( - self.unify, self._estimate_json, self._api_path + self.tamr, self._estimate_json, self._api_path ) self.assertFalse(estimate.is_up_to_date) self.assertEqual(estimate.total_estimate, self._estimate_json["totalEstimate"]) @@ -51,11 +51,11 @@ def test_refresh(self): responses.add(responses.GET, f"{self._url_base}/operations/24", json=updated) estimate = EstimatedPairCounts.from_json( - self.unify, self._estimate_json, self._api_path + self.tamr, self._estimate_json, self._api_path ) generated = estimate.refresh(poll_interval_seconds=0) - created = Operation.from_json(self.unify, updated) + created = Operation.from_json(self.tamr, updated) self.assertEqual(repr(generated), repr(created)) _url_base = "http://localhost:9100/api/versioned/v1" diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index 1b0aa6ae..f821ff7e 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -10,7 +10,7 @@ class TestProject(TestCase): def setUp(self): auth = UsernamePasswordAuth("username", "password") - self.unify = Client(auth) + self.tamr = Client(auth) @responses.activate def test_project_add_input_dataset(self): @@ -26,8 +26,8 @@ def test_project_add_input_dataset(self): responses.GET, self.input_datasets_url, json=self.get_input_datasets_json ) - dataset = self.unify.datasets.by_external_id(self.dataset_external_id) - project = self.unify.projects.by_external_id(self.project_external_id) + dataset = self.tamr.datasets.by_external_id(self.dataset_external_id) + project = self.tamr.projects.by_external_id(self.project_external_id) project.add_input_dataset(dataset) alias = project.api_path + "/inputDatasets" input_datasets = project.client.get(alias).successful().json() @@ -43,7 +43,7 @@ def test_project_remove_input_dataset(self): ) responses.add(responses.GET, self.input_datasets_url, json=[]) - project = Project(self.unify, self.project_json[0]) + project = Project(self.tamr, self.project_json[0]) dataset = next(project.input_datasets().stream()) response = project.remove_input_dataset(dataset) @@ -56,12 +56,12 @@ def test_project_remove_input_dataset(self): def test_project_by_external_id__raises_when_not_found(self): responses.add(responses.GET, self.projects_url, json=[]) with self.assertRaises(KeyError): - self.unify.projects.by_external_id(self.project_external_id) + self.tamr.projects.by_external_id(self.project_external_id) @responses.activate def test_project_by_external_id_succeeds(self): responses.add(responses.GET, self.projects_url, json=self.project_json) - actual_project = self.unify.projects.by_external_id(self.project_external_id) + actual_project = self.tamr.projects.by_external_id(self.project_external_id) self.assertEqual(self.project_json[0], actual_project._data) @responses.activate @@ -72,7 +72,7 @@ def test_project_attributes_get(self): self.project_attributes_url, json=self.project_attributes_json, ) - project = self.unify.projects.by_external_id(self.project_external_id) + project = self.tamr.projects.by_external_id(self.project_external_id) attributes = list(project.attributes) self.assertEqual(len(self.project_attributes_json), len(attributes)) @@ -98,21 +98,21 @@ def test_project_attributes_post(self): json=self.project_attributes_json[0], status=204, ) - project = self.unify.projects.by_external_id(self.project_external_id) + project = self.tamr.projects.by_external_id(self.project_external_id) # project.attributes.create MUST make a POST request to self.project_attributes_url # If it posts to some other URL, responses will raise an exception; # If it does not post to any URL, responses will also raise an exception. project.attributes.create(self.project_attributes_json[0]) def test_project_get_input_datasets(self): - p = Project(self.unify, self.project_json[0]) + p = Project(self.tamr, self.project_json[0]) datasets = p.input_datasets() self.assertEqual(datasets.api_path, "projects/1/inputDatasets") @responses.activate def test_return_attribute_collection(self): responses.add(responses.GET, self.projects_url, json=self.project_json) - project = self.unify.projects.by_external_id(self.project_external_id) + project = self.tamr.projects.by_external_id(self.project_external_id) attribute_configs = project.attribute_configurations() self.assertEqual( attribute_configs.api_path, "projects/1/attributeConfigurations" @@ -123,7 +123,7 @@ def test_return_attribute_mapping(self): responses.add(responses.GET, self.projects_url, json=self.project_json) map_url = "http://localhost:9100/api/versioned/v1/projects/1/attributeMappings" responses.add(responses.GET, map_url, json=self.mappings_json) - project = self.unify.projects.by_external_id(self.project_external_id) + project = self.tamr.projects.by_external_id(self.project_external_id) attribute_mappings = project.attribute_mappings() self.assertEqual( attribute_mappings.by_resource_id("19689-14").unified_dataset_name, diff --git a/tests/unit/test_published_cluster_version.py b/tests/unit/test_published_cluster_version.py index cc45561c..80fc1623 100644 --- a/tests/unit/test_published_cluster_version.py +++ b/tests/unit/test_published_cluster_version.py @@ -21,7 +21,7 @@ class PublishedClusterTest(TestCase): def setUp(self): auth = UsernamePasswordAuth("username", "password") - self.unify = Client(auth) + self.tamr = Client(auth) def test_metric(self): metric_json = {"metricName": "recordCount", "metricValue": "1"} @@ -85,7 +85,7 @@ def create_callback(request, snoop): snoop["payload"] = request.body return 200, {}, "\n".join(json.dumps(c) for c in self._versions_json) - p = Project.from_json(self.unify, self._project_json).as_mastering() + p = Project.from_json(self.tamr, self._project_json).as_mastering() post_url = f"http://localhost:9100/api/versioned/v1/{p.api_path}/publishedClusterVersions" snoop = {} responses.add_callback( @@ -109,7 +109,7 @@ def create_callback(request, snoop): snoop["payload"] = request.body return 200, {}, "\n".join(json.dumps(c) for c in self._record_versions_json) - p = Project.from_json(self.unify, self._project_json).as_mastering() + p = Project.from_json(self.tamr, self._project_json).as_mastering() base_url = "http://localhost:9100/api/versioned/v1" post_url = f"{base_url}/{p.api_path}/recordPublishedClusterVersions" snoop = {} diff --git a/tests/unit/test_published_clusters.py b/tests/unit/test_published_clusters.py index beda2ae7..e6a28fa9 100644 --- a/tests/unit/test_published_clusters.py +++ b/tests/unit/test_published_clusters.py @@ -14,7 +14,7 @@ class PublishedClusterTest(TestCase): def setUp(self): auth = UsernamePasswordAuth("username", "password") - self.unify = Client(auth) + self.tamr = Client(auth) @responses.activate def test_published_clusters(self): @@ -34,7 +34,7 @@ def test_published_clusters(self): responses.add(responses.GET, datasets_url, json=self._datasets_json) responses.add(responses.POST, refresh_url, json=self._refresh_json) responses.add(responses.GET, operations_url, json=self._operations_json) - project = self.unify.projects.by_resource_id(project_id) + project = self.tamr.projects.by_resource_id(project_id) actual_published_clusters_dataset = project.as_mastering().published_clusters() actual_published_clusters_dataset.refresh(poll_interval_seconds=0) self.assertEqual( @@ -48,10 +48,10 @@ def test_published_clusters_configuration(self): config_url = f"{self._base_url}/{path}" responses.add(responses.GET, config_url, json=self._config_json) - p = Project(self.unify, self._project_config_json).as_mastering() + p = Project(self.tamr, self._project_config_json).as_mastering() config = p.published_clusters_configuration() created = PublishedClustersConfiguration.from_json( - self.unify, self._config_json, path + self.tamr, self._config_json, path ) self.assertEqual(repr(config), repr(created)) @@ -66,7 +66,7 @@ def test_delete_published_clusters_configuration(self): responses.add(responses.GET, config_url, json=self._config_json) responses.add(responses.DELETE, config_url, status=405) - p = Project(self.unify, self._project_config_json).as_mastering() + p = Project(self.tamr, self._project_config_json).as_mastering() config = p.published_clusters_configuration() self.assertRaises(HTTPError, config.delete) @@ -82,7 +82,7 @@ def test_refresh_ids(self): responses.add(responses.GET, datasets_url, json=self._datasets_json) responses.add(responses.POST, refresh_url, json=self._operations_json) - p = Project(self.unify, self._project_config_json).as_mastering() + p = Project(self.tamr, self._project_config_json).as_mastering() d = p.published_cluster_ids() op = d.refresh(poll_interval_seconds=0) @@ -101,7 +101,7 @@ def test_refresh_stats(self): responses.add(responses.GET, datasets_url, json=self._datasets_json) responses.add(responses.POST, refresh_url, json=self._operations_json) - p = Project(self.unify, self._project_config_json).as_mastering() + p = Project(self.tamr, self._project_config_json).as_mastering() d = p.published_cluster_stats() op = d.refresh(poll_interval_seconds=0) diff --git a/tests/unit/test_published_clusters_with_data.py b/tests/unit/test_published_clusters_with_data.py index 86802bbe..c2f5abb3 100644 --- a/tests/unit/test_published_clusters_with_data.py +++ b/tests/unit/test_published_clusters_with_data.py @@ -55,7 +55,7 @@ def test_published_clusters_with_data(): datasets_json = [pcwd_json] - unify = Client(UsernamePasswordAuth("username", "password")) + tamr = Client(UsernamePasswordAuth("username", "password")) project_id = "1" @@ -71,7 +71,7 @@ def test_published_clusters_with_data(): responses.add(responses.GET, datasets_url, json=datasets_json) responses.add(responses.POST, refresh_url, json=refresh_json) - project = unify.projects.by_resource_id(project_id) + project = tamr.projects.by_resource_id(project_id) actual_pcwd_dataset = project.as_mastering().published_clusters_with_data() assert actual_pcwd_dataset.name == pcwd_json["name"] diff --git a/tests/unit/test_record_clusters_with_data.py b/tests/unit/test_record_clusters_with_data.py index ce32e94d..46666703 100644 --- a/tests/unit/test_record_clusters_with_data.py +++ b/tests/unit/test_record_clusters_with_data.py @@ -56,7 +56,7 @@ def test_record_clusters_with_data(): datasets_json = [rcwd_json] - unify = Client(UsernamePasswordAuth("username", "password")) + tamr = Client(UsernamePasswordAuth("username", "password")) project_id = "1" @@ -72,7 +72,7 @@ def test_record_clusters_with_data(): responses.add(responses.GET, datasets_url, json=datasets_json) responses.add(responses.POST, refresh_url, json=refresh_json) - project = unify.projects.by_resource_id(project_id) + project = tamr.projects.by_resource_id(project_id) actual_rcwd_dataset = project.as_mastering().record_clusters_with_data() assert actual_rcwd_dataset.name == rcwd_json["name"] diff --git a/tests/unit/test_strings.py b/tests/unit/test_strings.py index 14d6f277..304ff84f 100644 --- a/tests/unit/test_strings.py +++ b/tests/unit/test_strings.py @@ -5,10 +5,10 @@ def test_client_repr(): auth = UsernamePasswordAuth("username", "password") - unify = Client(auth) + tamr = Client(auth) full_clz_name = "tamr_unify_client.client.Client" - rstr = f"{unify!r}" + rstr = f"{tamr!r}" assert rstr.startswith(f"{full_clz_name}(") assert "http" in rstr @@ -16,8 +16,8 @@ def test_client_repr(): assert rstr.endswith(")") # further testing when Client has optional arguments - unify = Client(auth, protocol="http", port=1234, base_path="foo/bar") - rstr = f"{unify!r}" + tamr = Client(auth, protocol="http", port=1234, base_path="foo/bar") + rstr = f"{tamr!r}" assert "'http'" in rstr assert "1234" in rstr diff --git a/tests/unit/test_taxonomy.py b/tests/unit/test_taxonomy.py index fd09d130..b7f35d57 100644 --- a/tests/unit/test_taxonomy.py +++ b/tests/unit/test_taxonomy.py @@ -16,7 +16,7 @@ class TestTaxonomy(TestCase): def setUp(self): auth = UsernamePasswordAuth("username", "password") - self.unify = Client(auth) + self.tamr = Client(auth) @responses.activate def test_categories(self): @@ -25,12 +25,12 @@ def test_categories(self): ) responses.add(responses.GET, cat_url, json=self._categories_json) - t = Taxonomy(self.unify, self._taxonomy_json) + t = Taxonomy(self.tamr, self._taxonomy_json) c = list(t.categories()) cats = [ - Category(self.unify, self._categories_json[0]), - Category(self.unify, self._categories_json[1]), + Category(self.tamr, self._categories_json[0]), + Category(self.tamr, self._categories_json[1]), ] self.assertEqual(repr(c), repr(cats)) @@ -41,7 +41,7 @@ def test_by_id(self): ) responses.add(responses.GET, cat_url, json=self._categories_json[0]) - c = CategoryCollection(self.unify, "projects/1/taxonomy/categories") + c = CategoryCollection(self.tamr, "projects/1/taxonomy/categories") r = c.by_relative_id("projects/1/taxonomy/categories/1") self.assertEqual(r._data, self._categories_json[0]) r = c.by_resource_id("1") @@ -56,7 +56,7 @@ def test_create(self): responses.add(responses.POST, post_url, json=self._categories_json[0]) alias = "projects/1/taxonomy/categories" - coll = CategoryCollection(self.unify, alias) + coll = CategoryCollection(self.tamr, alias) creation_spec = { "name": self._categories_json[0]["name"], @@ -80,7 +80,7 @@ def create_callback(request, snoop): ) alias = "projects/1/taxonomy/categories" - coll = CategoryCollection(self.unify, alias) + coll = CategoryCollection(self.tamr, alias) creation_specs = [ { @@ -108,7 +108,7 @@ def test_delete(self): responses.add(responses.GET, url, status=404) project = Project( - self.unify, {"type": "CATEGORIZATION"}, "projects/1" + self.tamr, {"type": "CATEGORIZATION"}, "projects/1" ).as_categorization() taxonomy = project.taxonomy() self.assertEqual(taxonomy._data, self._taxonomy_json) @@ -124,7 +124,7 @@ def test_delete_category(self): responses.add(responses.DELETE, url, status=204) responses.add(responses.GET, url, status=404) - categories = CategoryCollection(self.unify, "projects/1/taxonomy/categories") + categories = CategoryCollection(self.tamr, "projects/1/taxonomy/categories") category = categories.by_resource_id("1") self.assertEqual(category._data, self._categories_json[0]) diff --git a/tests/unit/test_upstream_dataset.py b/tests/unit/test_upstream_dataset.py index f794a2e2..a5dac3d6 100644 --- a/tests/unit/test_upstream_dataset.py +++ b/tests/unit/test_upstream_dataset.py @@ -54,7 +54,7 @@ def test_upstream_dataset(): "resourceId": "8", } - unify = Client(UsernamePasswordAuth("username", "password")) + tamr = Client(UsernamePasswordAuth("username", "password")) url_prefix = "http://localhost:9100/api/versioned/v1/" dataset_url = url_prefix + "datasets/12" @@ -65,7 +65,7 @@ def test_upstream_dataset(): responses.add(responses.GET, upstream_url, json=upstream_json) responses.add(responses.GET, upstream_ds_url, json=upstream_ds_json) - project_ds = unify.datasets.by_relative_id("datasets/12") + project_ds = tamr.datasets.by_relative_id("datasets/12") actual_upstream_ds = project_ds.upstream_datasets() uri_dataset = actual_upstream_ds[0].dataset() From 84287530d473bf7cb14965b7d9cc8765592595ee Mon Sep 17 00:00:00 2001 From: Charlotte Moremen Date: Wed, 14 Aug 2019 09:36:06 -0400 Subject: [PATCH 142/632] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd4eaea6..cb9cc46c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ **BUG FIXES** - [#235](https://github.com/Datatamer/unify-client-python/issues/235) Making `AttributeCollection` retrieve attributes directly instead of by streaming + - [#234](https://github.com/Datatamer/tamr-client/issues/234) Project's `resource`'s `add_input_dataset` now uses params instead of constructing resource ID manually ## 0.8.0 **BREAKING CHANGES** From e83d51870266752888201b1e5a69c830323c5680 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 13 Aug 2019 11:01:19 -0400 Subject: [PATCH 143/632] attribute configuration builder --- .../attribute_configuration/resource.py | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/tamr_unify_client/project/attribute_configuration/resource.py b/tamr_unify_client/project/attribute_configuration/resource.py index be1b7ee2..484953c3 100644 --- a/tamr_unify_client/project/attribute_configuration/resource.py +++ b/tamr_unify_client/project/attribute_configuration/resource.py @@ -1,3 +1,5 @@ +from copy import deepcopy + from tamr_unify_client.base_resource import BaseResource @@ -58,6 +60,14 @@ def attribute_name(self): """:type: str""" return self._data.get("attributeName") + def spec(self): + """Returns this attribute configuration's spec. + + :return: The spec of this attribute configuration. + :rtype: :class:`~tamr_unify_client.project.attribute_configuration.resource.AttributeConfigurationSpec` + """ + return AttributeConfigurationSpec.of(self) + def __repr__(self): return ( f"{self.__class__.__module__}." @@ -72,3 +82,121 @@ def __repr__(self): f"numeric_field_resolution={self.numeric_field_resolution!r}, " f"attribute_name={self.attribute_name!r})" ) + + +class AttributeConfigurationSpec: + """A representation of the server view of an attribute configuration.""" + + def __init__(self, client, data, api_path): + self.client = client + self._data = data + self.api_path = api_path + + @staticmethod + def of(resource): + """Creates an attribute configuration spec from an attribute configuration. + + :param resource: The existing attribute configuration. + :type resource: :class:`~tamr_unify_client.project.attribute_configuration.resource.AttributeConfiguration` + :return: The corresponding attribute creation spec. + :rtype: :class:`~tamr_unify_client.project.attribute_configuration.resource.AttributeConfigurationSpec` + """ + return AttributeConfigurationSpec( + resource.client, deepcopy(resource._data), resource.api_path + ) + + def from_data(self, data): + """Creates a spec with the same client and API path as this one, but new data. + + :param data: The data for the new spec. + :type data: dict + :return: The new spec. + :rtype: :class:`~tamr_unify_client.project.attribute_configuration.resource.AttributeConfigurationSpec` + """ + return AttributeConfigurationSpec(self.client, data, self.api_path) + + def to_dict(self): + """Returns a version of this spec that conforms to the API representation. + + :returns: The spec's dict. + :rtype: dict + """ + return deepcopy(self._data) + + def with_attribute_role(self, new_attribute_role): + """Creates a new spec with the same properties, updating attribute role. + + :param new_attribute_role: The new attribute role. + :type new_attribute_role: str + :return: A new spec. + :rtype: :class:`~tamr_unify_client.project.attribute_configuration.resource.AttributeConfigurationSpec` + """ + return self.from_data({**self._data, "attributeRole": new_attribute_role}) + + def with_similarity_function(self, new_similarity_function): + """Creates a new spec with the same properties, updating similarity function. + + :param new_similarity_function: The new similarity function. + :type new_similarity_function: str + :return: A new spec. + :rtype: :class:`~tamr_unify_client.project.attribute_configuration.resource.AttributeConfigurationSpec` + """ + return self.from_data( + {**self._data, "similarityFunction": new_similarity_function} + ) + + def with_enabled_for_ml(self, new_enabled_for_ml): + """Creates a new spec with the same properties, updating enabled for ML. + + :param new_enabled_for_ml: Whether the builder is enabled for ML. + :type new_enabled_for_ml: bool + :return: A new spec. + :rtype: :class:`~tamr_unify_client.project.attribute_configuration.resource.AttributeConfigurationSpec` + """ + return self.from_data({**self._data, "enabledForMl": new_enabled_for_ml}) + + def with_tokenizer(self, new_tokenizer): + """Creates a new spec with the same properties, updating tokenizer. + + :param new_tokenizer: The new tokenizer. + :type new_tokenizer: str + :return: A new spec. + :rtype: :class:`~tamr_unify_client.project.attribute_configuration.resource.AttributeConfigurationSpec` + """ + return self.from_data({**self._data, "tokenizer": new_tokenizer}) + + def with_numeric_field_resolution(self, new_numeric_field_resolution): + """Creates a new spec with the same properties, updating numeric field resolution. + + :param new_numeric_field_resolution: The new numeric field resolution. + :type new_numeric_field_resolution: str + :return: A new spec. + :rtype: :class:`~tamr_unify_client.project.attribute_configuration.resource.AttributeConfigurationSpec` + """ + return self.from_data( + {**self._data, "numericFieldResolution": new_numeric_field_resolution} + ) + + def put(self): + """Updates the attribute configuration on the server. + + :return: The modified attribute configuration. + :rtype: :class:`~tamr_unify_client.project.attribute_configuration.resource.AttributeConfiguration` + """ + new_data = self.client.put(self.api_path, json=self._data).successful().json() + return AttributeConfiguration.from_json(self.client, new_data, self.api_path) + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"relative_id={self._data['relativeId']!r}, " + f"id={self._data['id']!r}, " + f"relative_attribute_id={self._data['relativeAttributeId']!r}, " + f"attribute_role={self._data['attributeRole']!r}, " + f"similarity_function={self._data['similarityFunction']!r}, " + f"enabled_for_ml={self._data['enabledForMl']!r}, " + f"tokenizer={self._data['tokenizer']!r}, " + f"numeric_field_resolution={self._data['numericFieldResolution']!r}, " + f"attribute_name={self._data['attributeName']!r})" + ) From 09dbd82e2c9b0c11a375dc281c3a9e36b608a668 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 13 Aug 2019 11:16:37 -0400 Subject: [PATCH 144/632] testing attribute configuration builder --- tests/unit/test_attribute_configuration.py | 65 +++++++++++++++++++--- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/tests/unit/test_attribute_configuration.py b/tests/unit/test_attribute_configuration.py index 5bd76f4a..cd9b2057 100644 --- a/tests/unit/test_attribute_configuration.py +++ b/tests/unit/test_attribute_configuration.py @@ -1,3 +1,5 @@ +from functools import partial +import json from unittest import TestCase from requests import HTTPError @@ -57,22 +59,57 @@ def test_resource_from_json(self): @responses.activate def test_delete(self): - base = "http://localhost:9100/api/versioned/v1" - alias = "projects/1/attributeConfigurations" - attribute_id = "26" - - url = f"{base}/{alias}/{attribute_id}" + url = f"{self._base}/{self._alias}/{self._attribute_id}" responses.add(responses.GET, url, json=self._ac_json) responses.add(responses.DELETE, url, status=204) responses.add(responses.GET, url, status=404) - collection = AttributeConfigurationCollection(self.tamr, alias) - config = collection.by_resource_id(attribute_id) + collection = AttributeConfigurationCollection(self.tamr, self._alias) + config = collection.by_resource_id(self._attribute_id) + self.assertEqual(config._data, self._ac_json) response = config.delete() self.assertEqual(response.status_code, 204) - self.assertRaises(HTTPError, lambda: collection.by_resource_id(attribute_id)) + self.assertRaises( + HTTPError, lambda: collection.by_resource_id(self._attribute_id) + ) + + @responses.activate + def test_update(self): + def create_callback(request, snoop): + snoop["payload"] = request.body + return 200, {}, json.dumps(self._updated_ac_json) + + configs_url = f"{self._base}/{self._alias}" + config_url = f"{configs_url}/{self._attribute_id}" + + snoop_dict = {} + responses.add(responses.GET, config_url, json=self._ac_json) + responses.add_callback( + responses.PUT, config_url, partial(create_callback, snoop=snoop_dict) + ) + configs = AttributeConfigurationCollection(self.tamr, self._alias) + config = configs.by_resource_id(self._attribute_id) + + temp_spec = config.spec().with_attribute_role("SUM_ATTRIBUTE") + new_config = ( + temp_spec.with_enabled_for_ml(False) + .with_similarity_function("ABSOLUTE_DIFF") + .with_tokenizer("BIGRAM") + .put() + ) + + self.assertEqual(new_config._data, self._updated_ac_json) + self.assertEqual(json.loads(snoop_dict["payload"]), self._updated_ac_json) + self.assertEqual(config._data, self._ac_json) + + # checking that intermediate didn't change + self.assertTrue(temp_spec.to_dict()["enabledForMl"]) + + _base = "http://localhost:9100/api/versioned/v1" + _alias = "projects/1/attributeConfigurations" + _attribute_id = "26" _ac_json = { "id": "unify://unified-data/v1/projects/1/attributeConfigurations/26", @@ -85,3 +122,15 @@ def test_delete(self): "numericFieldResolution": [], "attributeName": "surname", } + + _updated_ac_json = { + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/26", + "relativeId": "projects/1/attributeConfigurations/26", + "relativeAttributeId": "datasets/8/attributes/surname", + "attributeRole": "SUM_ATTRIBUTE", + "similarityFunction": "ABSOLUTE_DIFF", + "enabledForMl": False, + "tokenizer": "BIGRAM", + "numericFieldResolution": [], + "attributeName": "surname", + } From 0df84fd1d0065ef83d7a605d14b70725835f44f8 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 13 Aug 2019 11:52:07 -0400 Subject: [PATCH 145/632] dataset builder --- tamr_unify_client/dataset/resource.py | 95 +++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index 39ea38e8..98c76547 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -1,3 +1,5 @@ +from copy import deepcopy + import simplejson as json from tamr_unify_client.attribute.collection import AttributeCollection @@ -262,6 +264,14 @@ def upstream_datasets(self): return [DatasetURI(self.client, uri) for uri in resources] + def spec(self): + """Returns this dataset's spec. + + :return: The spec of this dataset. + :rtype: :class:`~tamr_unify_client.dataset.resource.DatasetSpec` + """ + return DatasetSpec.of(self) + @property def __geo_interface__(self): """Retrieve a representation of this dataset that conforms to the Python Geo Interface. @@ -434,3 +444,88 @@ def _geo_attr_names(): "polygon", "multiPolygon", } + + +class DatasetSpec: + """A representation of the server view of a dataset.""" + + def __init__(self, client, data, api_path): + self.client = client + self._data = data + self.api_path = api_path + + @staticmethod + def of(resource): + """Creates a dataset spec from a dataset. + + :param resource: The existing dataset. + :type resource: :class:`~tamr_unify_client.dataset.resource.Dataset` + :return: The corresponding dataset spec. + :rtype: :class:`~tamr_unify_client.dataset.resource.DatasetSpec` + """ + return DatasetSpec(resource.client, deepcopy(resource._data), resource.api_path) + + def from_data(self, data): + """Creates a spec with the same client and API path as this one, but new data. + + :param data: The data for the new spec. + :type data: dict + :return: The new spec. + :rtype: :class:`~tamr_unify_client.dataset.resource.DatasetSpec` + """ + return DatasetSpec(self.client, data, self.api_path) + + def to_dict(self): + """Returns a version of this spec that conforms to the API representation. + + :returns: The spec's dict. + :rtype: dict + """ + return deepcopy(self._data) + + def with_external_id(self, new_external_id): + """Creates a new spec with the same properties, updating external ID. + + :param new_external_id: The new external ID. + :type new_external_id: str + :return: A new spec. + :rtype: :class:`~tamr_unify_client.dataset.resource.DatasetSpec` + """ + return self.from_data({**self._data, "externalId": new_external_id}) + + def with_description(self, new_description): + """Creates a new spec with the same properties, updating description. + + :param new_description: The new description. + :type new_description: str + :return: A new spec. + :rtype: :class:`~tamr_unify_client.dataset.resource.DatasetSpec` + """ + return self.from_data({**self._data, "description": new_description}) + + def with_tags(self, new_tags): + """Creates a new spec with the same properties, updating tags. + + :param new_tags: The new tags. + :type new_tags: list[str] + :return: A new spec. + :rtype: :class:`~tamr_unify_client.dataset.resource.DatasetSpec` + """ + return self.from_data({**self._data, "tags": new_tags}) + + def put(self): + """Updates the dataset on the server. + + :return: The modified dataset. + :rtype: :class:`~tamr_unify_client.dataset.resource.Dataset` + """ + new_data = self.client.put(self.api_path, json=self._data).successful().json() + return Dataset.from_json(self.client, new_data, self.api_path) + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"relative_id={self._data['relativeId']!r}, " + f"name={self._data['name']!r})" + ) From cb584bae667a619e5b26f709822dab249f44208f Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 13 Aug 2019 11:52:20 -0400 Subject: [PATCH 146/632] test dataset builder --- tests/unit/test_dataset.py | 65 +++++++++++++++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_dataset.py b/tests/unit/test_dataset.py index bf454288..85f20a88 100644 --- a/tests/unit/test_dataset.py +++ b/tests/unit/test_dataset.py @@ -1,3 +1,5 @@ +from functools import partial +import json from unittest import TestCase from requests import HTTPError @@ -14,10 +16,9 @@ def setUp(self): @responses.activate def test_delete(self): - url = "http://localhost:9100/api/versioned/v1/datasets/1" - responses.add(responses.GET, url, json=self._dataset_json) - responses.add(responses.DELETE, url, status=204) - responses.add(responses.GET, url, status=404) + responses.add(responses.GET, self._url, json=self._dataset_json) + responses.add(responses.DELETE, self._url, status=204) + responses.add(responses.GET, self._url, status=404) dataset = self.tamr.datasets.by_resource_id("1") self.assertEqual(dataset._data, self._dataset_json) @@ -26,6 +27,40 @@ def test_delete(self): self.assertEqual(response.status_code, 204) self.assertRaises(HTTPError, lambda: self.tamr.datasets.by_resource_id("1")) + @responses.activate + def test_update(self): + def create_callback(request, snoop): + snoop["payload"] = request.body + return 200, {}, json.dumps(self._updated_dataset_json) + + snoop_dict = {} + responses.add(responses.GET, self._url, json=self._dataset_json) + responses.add_callback( + responses.PUT, self._url, partial(create_callback, snoop=snoop_dict) + ) + + dataset = self.tamr.datasets.by_resource_id("1") + + temp_spec = dataset.spec().with_description( + self._updated_dataset_json["description"] + ) + new_dataset = ( + temp_spec.with_external_id(self._updated_dataset_json["externalId"]) + .with_tags(self._updated_dataset_json["tags"]) + .put() + ) + + self.assertEqual(new_dataset._data, self._updated_dataset_json) + self.assertEqual(json.loads(snoop_dict["payload"]), self._updated_dataset_json) + self.assertEqual(dataset._data, self._dataset_json) + + # checking that intermediate didn't change + self.assertEqual( + temp_spec.to_dict()["externalId"], self._dataset_json["externalId"] + ) + + _url = "http://localhost:9100/api/versioned/v1/datasets/1" + _dataset_json = { "id": "unify://unified-data/v1/datasets/1", "externalId": "1", @@ -47,3 +82,25 @@ def test_delete(self): "relativeId": "datasets/1", "upstreamDatasetIds": [], } + + _updated_dataset_json = { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "dataset1", + "name": "dataset 1 name", + "description": "updated description", + "version": "dataset 1 version", + "keyAttributeNames": ["tamr_id"], + "tags": ["new", "tags"], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version", + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version", + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [], + } From 88c91b0ca850cf5d2c5ea1f2cca74251218f6d67 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 13 Aug 2019 11:30:27 -0400 Subject: [PATCH 147/632] docs and changelog --- CHANGELOG.md | 2 ++ docs/developer-interface.rst | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 666aea46..3211ef94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ - [#218](https://github.com/Datatamer/unify-client-python/issues/218) Delete a `BaseResource` - [#233](https://github.com/Datatamer/unify-client-python/issues/233) Remove an input dataset from a project - [#67](https://github.com/Datatamer/unify-client-python/issues/67) Create a dataset from a pandas `DataFrame` + - [#222](https://github.com/Datatamer/unify-client-python/issues/222) Dataset spec to update an existing dataset + - [#225](https://github.com/Datatamer/unify-client-python/issues/225) Attribute configuration spec to update an existing attribute configuration **BUG FIXES** - [#235](https://github.com/Datatamer/unify-client-python/issues/235) Making `AttributeCollection` retrieve attributes directly instead of by streaming diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index 6e39e796..a76fda84 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -78,6 +78,12 @@ Dataset .. autoclass:: tamr_unify_client.dataset.resource.Dataset :members: +Dataset Spec +^^^^^^^^^^^^ + +.. autoclass:: tamr_unify_client.dataset.resource.DatasetSpec + :members: + Dataset Collection ^^^^^^^^^^^^^^^^^^ @@ -200,6 +206,12 @@ Attribute Configuration .. autoclass:: tamr_unify_client.project.attribute_configuration.resource.AttributeConfiguration :members: +Attribute Configuration Spec +"""""""""""""""""""""""""""" + +.. autoclass:: tamr_unify_client.project.attribute_configuration.resource.AttributeConfigurationSpec + :members: + Attribute Configuration Collection """""""""""""""""""""""""""""""""" From 0440445365a8c739d67d4726b1d19e5b8697ad79 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Wed, 14 Aug 2019 16:10:56 -0400 Subject: [PATCH 148/632] removing unify from docs --- docs/contributor-guide.rst | 12 +++++------ docs/index.rst | 12 +++++------ docs/user-guide/advanced-usage.rst | 30 +++++++++++++------------- docs/user-guide/faq.rst | 2 +- docs/user-guide/installation.rst | 4 ++-- docs/user-guide/quickstart.rst | 14 ++++++------ docs/user-guide/secure-credentials.rst | 10 ++++----- docs/user-guide/workflows.rst | 16 +++++++------- 8 files changed, 50 insertions(+), 50 deletions(-) diff --git a/docs/contributor-guide.rst b/docs/contributor-guide.rst index bce59bbb..c66573e4 100644 --- a/docs/contributor-guide.rst +++ b/docs/contributor-guide.rst @@ -4,14 +4,14 @@ Contributor Guide Code of Conduct --------------- -See `CODE_OF_CONDUCT.md `_ +See `CODE_OF_CONDUCT.md `_ .. _bug-reports-feature-requests: 🐛 Bug Reports / 🙋 Feature Requests ------------------------------------ -Please leave bug reports and feature requests as `Github issues `_ . +Please leave bug reports and feature requests as `Github issues `_ . ---- @@ -26,7 +26,7 @@ should be avoided as much as possible. For larger, new features: - `Open an RFC issue `_ . + `Open an RFC issue `_ . Discuss the feature with project maintainers to be sure that your change fits with the project vision and that you won't be wasting effort going in the wrong direction. @@ -35,7 +35,7 @@ For larger, new features: Contributions / PRs should follow the `Forking Workflow `_ : - 1. Fork it: https://github.com/[your-github-username]/unify-client-python/fork + 1. Fork it: https://github.com/[your-github-username]/tamr-client/fork 2. Create your feature branch:: git checkout -b my-new-feature @@ -69,8 +69,8 @@ see the `official documentation `_ . 2. Clone your fork and ``cd`` into the project:: - git clone https://github.com//unify-client-python - cd unify-client-python + git clone https://github.com//tamr-client + cd tamr-client 3. Use ``pyenv`` to install a compatible Python version (``3.6`` or newer; e.g. ``3.7.3``):: diff --git a/docs/index.rst b/docs/index.rst index 6c58de38..08506409 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,16 +13,16 @@ Example import os # grab credentials from environment variables - username = os.environ['UNIFY_USERNAME'] - password = os.environ['UNIFY_PASSWORD'] + username = os.environ['TAMR_USERNAME'] + password = os.environ['TAMR_PASSWORD'] auth = UsernamePasswordAuth(username, password) - host = 'localhost' # replace with your Tamr Unify host - unify = Client(auth, host=host) + host = 'localhost' # replace with your Tamr host + tamr = Client(auth, host=host) - # programmatically interace with Tamr Unify! + # programmatically interace with Tamr! # e.g. refresh your project's Unified Dataset - project = unify.projects.by_resource_id('3') + project = tamr.projects.by_resource_id('3') ud = project.unified_dataset() op = ud.refresh() assert op.succeeded() diff --git a/docs/user-guide/advanced-usage.rst b/docs/user-guide/advanced-usage.rst index 088d66e1..cea04ac9 100644 --- a/docs/user-guide/advanced-usage.rst +++ b/docs/user-guide/advanced-usage.rst @@ -24,11 +24,11 @@ You can set up HTTP-API-call logging on any client via standard `Python logging mechanisms `_ :: from tamr_unify_client import Client - from unify_api_v1.auth import UsernamePasswordAuth + from tamr_unify_client import UsernamePasswordAuth import logging auth = UsernamePasswordAuth("username", "password") - unify = Client(auth) + tamr = Client(auth) # Reload the `logging` library since other libraries (like `requests`) already # configure logging differently. See: https://stackoverflow.com/a/53553516/1490091 @@ -38,7 +38,7 @@ standard `Python logging mechanisms Date: Fri, 9 Aug 2019 14:19:53 -0400 Subject: [PATCH 149/632] attribute builder --- tamr_unify_client/attribute/resource.py | 80 +++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/tamr_unify_client/attribute/resource.py b/tamr_unify_client/attribute/resource.py index 335a963b..022d20f5 100644 --- a/tamr_unify_client/attribute/resource.py +++ b/tamr_unify_client/attribute/resource.py @@ -1,3 +1,5 @@ +from copy import deepcopy + from tamr_unify_client.attribute.type import AttributeType from tamr_unify_client.base_resource import BaseResource @@ -52,6 +54,14 @@ def is_nullable(self): """:type: bool""" return self._data.get("isNullable") + def spec(self): + """Returns a spec representation of this attribute. + + :return: The attribute spec. + :rtype: :class:`~tamr_unify_client.attribute.resource.AttributeSpec` + """ + return AttributeSpec.of(self) + def __repr__(self): return ( f"{self.__class__.__module__}." @@ -59,3 +69,73 @@ def __repr__(self): f"relative_id={self.relative_id!r}, " f"name={self.name!r})" ) + + +class AttributeSpec: + """A representation of the server view of an attribute""" + + def __init__(self, client, data, api_path): + self._data = data + self.client = client + self.api_path = api_path + + @staticmethod + def of(resource): + """Creates an attribute spec from an attribute. + + :param resource: The existing attribute. + :type resource: :class:`~tamr_unify_client.attribute.resource.Attribute` + :return: The corresponding attribute spec. + :rtype: :class:`~tamr_unify_client.attribute.resource.AttributeSpec` + """ + return AttributeSpec( + resource.client, deepcopy(resource._data), resource.api_path + ) + + def from_data(self, data): + """Creates a spec with the same client and API path as this one, but new data. + + :param data: The data for the new spec. + :type data: dict + :return: The new spec. + :rtype: :class:`~tamr_unify_client.attribute.resource.AttributeSpec` + """ + return AttributeSpec(self.client, data, self.api_path) + + def to_dict(self): + """Returns a version of this spec that conforms to the API representation. + + :returns: The spec's dict. + :rtype: dict + """ + return deepcopy(self._data) + + def with_description(self, new_description): + """Creates a new spec with the same properties, updating description. + + :param new_description: The new description. + :type new_description: str + :return: The new spec. + :rtype: :class:`~tamr_unify_client.attribute.resource.AttributeSpec` + """ + return self.from_data({**self._data, "description": new_description}) + + def put(self): + """Commits the changes and updates the attribute in Tamr. + + :return: The updated attribute. + :rtype: :class:`~tamr_unify_client.attribute.resource.Attribute` + """ + updated_attribute = ( + self.client.put(self.api_path, json=self._data).successful().json() + ) + return Attribute.from_json(self.client, updated_attribute, self.api_path) + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"relative_id={self.api_path!r}, " + f"name={self._data['name']!r}, " + f"description={self._data['description']!r})" + ) From f23d4f26c9da1e322f1034863d6818d03c68c567 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 9 Aug 2019 14:20:06 -0400 Subject: [PATCH 150/632] attribute builder tests --- tests/unit/test_attribute.py | 44 ++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/unit/test_attribute.py b/tests/unit/test_attribute.py index 6b001f49..6534dfed 100644 --- a/tests/unit/test_attribute.py +++ b/tests/unit/test_attribute.py @@ -1,3 +1,5 @@ +from functools import partial +import json from unittest import TestCase from requests import HTTPError @@ -91,6 +93,41 @@ def test_delete_attribute(self): HTTPError, lambda: dataset.attributes.by_resource_id("RowNum") ) + @responses.activate + def test_update_attribute(self): + def create_callback(request, snoop): + snoop["payload"] = request.body + return 200, {}, json.dumps(self._updated_attribute_json) + + relative_id = "dataset/1/attributes/RowNum" + attribute_url = f"http://localhost:9100/api/versioned/v1/{relative_id}" + snoop_dict = {} + responses.add_callback( + responses.PUT, attribute_url, partial(create_callback, snoop=snoop_dict) + ) + attribute = Attribute(self.tamr, self._attributes_json[0], relative_id) + + temp_spec = attribute.spec() + new_attribute = temp_spec.with_description( + self._updated_attribute_json["description"] + ).put() + self.assertEqual(new_attribute.name, self._updated_attribute_json["name"]) + self.assertEqual( + new_attribute.description, self._updated_attribute_json["description"] + ) + + self.assertEqual( + json.loads(snoop_dict["payload"]), self._updated_attribute_json + ) + + self.assertEqual(attribute.name, self._attributes_json[0]["name"]) + self.assertEqual(attribute.description, self._attributes_json[0]["description"]) + + # checking that intermediate didn't change + self.assertEqual( + temp_spec.to_dict()["description"], self._attributes_json[0]["description"] + ) + _dataset_json = { "id": "unify://unified-data/v1/datasets/1", "externalId": "number 1", @@ -173,3 +210,10 @@ def test_delete_attribute(self): "isNullable": False, }, ] + + _updated_attribute_json = { + "name": "RowNum", + "description": "Synthetic row number updated", + "type": {"baseType": "STRING", "attributes": []}, + "isNullable": False, + } From 70297af567e0b11a8c271ce8e318a8b4cbc404da Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 9 Aug 2019 14:20:14 -0400 Subject: [PATCH 151/632] docs and changelog --- CHANGELOG.md | 1 + docs/developer-interface.rst | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3211ef94..e64bdc6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - [#67](https://github.com/Datatamer/unify-client-python/issues/67) Create a dataset from a pandas `DataFrame` - [#222](https://github.com/Datatamer/unify-client-python/issues/222) Dataset spec to update an existing dataset - [#225](https://github.com/Datatamer/unify-client-python/issues/225) Attribute configuration spec to update an existing attribute configuration + - [#223](https://github.com/Datatamer/unify-client-python/issues/223) Update an attribute with an attribute spec **BUG FIXES** - [#235](https://github.com/Datatamer/unify-client-python/issues/235) Making `AttributeCollection` retrieve attributes directly instead of by streaming diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index a76fda84..c2de47a8 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -22,6 +22,12 @@ Attribute .. autoclass:: tamr_unify_client.attribute.resource.Attribute :members: +Attribute Spec +^^^^^^^^^^^^^^ + +.. autoclass:: tamr_unify_client.attribute.resource.AttributeSpec + :members: + Attribute Collection ^^^^^^^^^^^^^^^^^^^^ From 1fb2e072e683a1d5923a9c92405f000e01e7618f Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 15 Aug 2019 13:53:54 -0400 Subject: [PATCH 152/632] add to attribute configuration spec to allow creation --- .../attribute_configuration/resource.py | 29 ++++++--- ...test_attribute_configuration_collection.py | 60 ++++++++++++++++--- 2 files changed, 72 insertions(+), 17 deletions(-) diff --git a/tamr_unify_client/project/attribute_configuration/resource.py b/tamr_unify_client/project/attribute_configuration/resource.py index 484953c3..5db5f430 100644 --- a/tamr_unify_client/project/attribute_configuration/resource.py +++ b/tamr_unify_client/project/attribute_configuration/resource.py @@ -105,6 +105,15 @@ def of(resource): resource.client, deepcopy(resource._data), resource.api_path ) + @staticmethod + def new(): + """Creates a blank spec that could be used to construct a new attribute configuration. + + :return: The empty spec. + :rtype: :class:`~tamr_unify_client.project.attribute_configuration.resource.AttributeConfigurationSpec` + """ + return AttributeConfigurationSpec(None, {}, None) + def from_data(self, data): """Creates a spec with the same client and API path as this one, but new data. @@ -177,6 +186,16 @@ def with_numeric_field_resolution(self, new_numeric_field_resolution): {**self._data, "numericFieldResolution": new_numeric_field_resolution} ) + def with_attribute_name(self, new_attribute_name): + """Creates a new spec with the same properties, updating new attribute name. + + :param new_attribute_name: The new attribute name. + :type new_attribute_name: str + :return: A new spec. + :rtype: :class:`~tamr_unify_client.project.attribute_configuration.resource.AttributeConfigurationSpec` + """ + return self.from_data({**self._data, "attributeName": new_attribute_name}) + def put(self): """Updates the attribute configuration on the server. @@ -190,13 +209,5 @@ def __repr__(self): return ( f"{self.__class__.__module__}." f"{self.__class__.__qualname__}(" - f"relative_id={self._data['relativeId']!r}, " - f"id={self._data['id']!r}, " - f"relative_attribute_id={self._data['relativeAttributeId']!r}, " - f"attribute_role={self._data['attributeRole']!r}, " - f"similarity_function={self._data['similarityFunction']!r}, " - f"enabled_for_ml={self._data['enabledForMl']!r}, " - f"tokenizer={self._data['tokenizer']!r}, " - f"numeric_field_resolution={self._data['numericFieldResolution']!r}, " - f"attribute_name={self._data['attributeName']!r})" + f"dict={self._data})" ) diff --git a/tests/unit/test_attribute_configuration_collection.py b/tests/unit/test_attribute_configuration_collection.py index c7e306b4..6b1620d0 100644 --- a/tests/unit/test_attribute_configuration_collection.py +++ b/tests/unit/test_attribute_configuration_collection.py @@ -1,3 +1,5 @@ +from functools import partial +import json from unittest import TestCase import responses @@ -7,6 +9,10 @@ from tamr_unify_client.project.attribute_configuration.collection import ( AttributeConfigurationCollection, ) +from tamr_unify_client.project.attribute_configuration.resource import ( + AttributeConfigurationSpec, +) +from tamr_unify_client.project.resource import Project class TestAttributeConfigurationCollection(TestCase): @@ -37,20 +43,54 @@ def test_by_resource_id(self): @responses.activate def test_create(self): + def create_callback(request, snoop): + snoop["payload"] = json.loads(request.body) + return 204, {}, json.dumps(self.created_json) + url = ( f"http://localhost:9100/api/versioned/v1/projects/1/attributeConfigurations" ) project_url = f"http://localhost:9100/api/versioned/v1/projects/1" responses.add(responses.GET, project_url, json=self.project_json) - responses.add(responses.POST, url, json=self.create_json, status=204) - responses.add(responses.GET, url, json=self.create_json) + snoop_dict = {} + responses.add_callback( + responses.POST, url, partial(create_callback, snoop=snoop_dict) + ) + responses.add(responses.GET, url, json=self.created_json) attributeconfig = self.tamr.projects.by_resource_id( "1" ).attribute_configurations() create = attributeconfig.create(self.create_json) - self.assertEqual(create.relative_id, self.create_json["relativeId"]) + self.assertEqual(create.relative_id, self.created_json["relativeId"]) + self.assertEqual(snoop_dict["payload"], self.create_json) + + @responses.activate + def test_create_from_spec(self): + def create_callback(request, snoop): + snoop["payload"] = json.loads(request.body) + return 204, {}, json.dumps(self.created_json) + + url = ( + f"http://localhost:9100/api/versioned/v1/projects/1/attributeConfigurations" + ) + snoop_dict = {} + responses.add_callback( + responses.POST, url, partial(create_callback, snoop=snoop_dict) + ) + + configs = Project(self.tamr, self.project_json).attribute_configurations() + spec = ( + AttributeConfigurationSpec.new() + .with_attribute_name(self.create_json["attributeName"]) + .with_enabled_for_ml(self.create_json["enabledForMl"]) + .with_similarity_function(self.create_json["similarityFunction"]) + ) + create = configs.create(spec.to_dict()) + + self.assertEqual(create.relative_id, self.created_json["relativeId"]) + self.assertEqual(snoop_dict["payload"], self.create_json) @responses.activate def test_stream(self): @@ -65,15 +105,19 @@ def test_stream(self): self.assertEqual(self.acc_json, stream_content) create_json = { - "id": "unify://unified-data/v1/projects/1/attributeConfigurations/35", - "relativeId": "projects/1/attributeConfigurations/35", - "relativeAttributeId": "datasets/79/attributes/Tester", - "attributeRole": "", "similarityFunction": "ABSOLUTE_DIFF", "enabledForMl": False, + "attributeName": "Tester", + } + + created_json = { + **create_json, + "attributeRole": "", "tokenizer": "", "numericFieldResolution": [], - "attributeName": "Tester", + "id": "unify://unified-data/v1/projects/1/attributeConfigurations/35", + "relativeId": "projects/1/attributeConfigurations/35", + "relativeAttributeId": "datasets/79/attributes/Tester", } project_json = { From dc92d50a306b93785fc806227d7e2b325c4e1b80 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 15 Aug 2019 16:04:42 -0400 Subject: [PATCH 153/632] spec information in docs --- docs/index.rst | 1 + docs/user-guide/spec.rst | 98 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 docs/user-guide/spec.rst diff --git a/docs/index.rst b/docs/index.rst index 08506409..ace3dedb 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -38,6 +38,7 @@ User Guide user-guide/quickstart user-guide/secure-credentials user-guide/workflows + user-guide/spec user-guide/geo user-guide/advanced-usage diff --git a/docs/user-guide/spec.rst b/docs/user-guide/spec.rst new file mode 100644 index 00000000..3f522044 --- /dev/null +++ b/docs/user-guide/spec.rst @@ -0,0 +1,98 @@ +Creating and Modifying Resources +================================ + +Creating resources +------------------ + +Resources, such as projects, dataset, and attribute configurations, +can be created through their respective collections. +Each ``create`` function takes in a dictionary that conforms to the +`Tamr Public Docs `_ for creating that resource type:: + + spec = { + "name": "project", + "description": "Mastering Project", + "type": "DEDUP" + "unifiedDatasetName": "project_unified_dataset" + } + project = tamr.projects.create(spec) + +Using specs +----------- + +These dictionaries can also be created using spec classes. + +Each ``Resource`` has a corresponding ``ResourceSpec`` which can be used to build an +instance of that resource by specifying the value for each property. + +The spec can then be converted to a dictionary that can be passed to ``create``. + +For instance, to create a project:: + + spec = ( + ProjectSpec.new() + .with_name("Project") + .with_type("DEDUP") + .with_description("Mastering Project") + .with_unified_dataset_name("Project_unified_dataset") + ) + project = tamr.projects.create(spec.to_dict()) + + +Calling ``with_*`` on a spec creates a new spec with the same properties besides the +modified one. The original spec is unaltered, so it could be used multiple times:: + + base_spec = ( + ProjectSpec.new() + .with_type("DEDUP") + .with_description("Mastering Project") + ) + + specs = [] + for name in project_names: + spec = ( + base_spec.with_name(name) + .with_unified_dataset_name(name + "_unified_dataset") + ) + specs.append(spec) + + projects = [tamr.projects.create(spec.to_dict()) for spec in specs] + + +Creating a dataset +------------------ + +Datasets can be created as described above, but the dataset's schema and +records must then be handled separately. + +To combine all of these steps into one, ``DatasetCollection`` has a convenience +function ``create_from_dataframe`` that takes a +`Pandas DataFrame `_. +This makes it easy to create a Tamr dataset from a CSV:: + + import pandas as pd + + df = pd.read_csv("my_data.csv") + dataset = tamr.datasets.create_from_dataframe(df, "primary key name", "My Data") + + +This will create a dataset called "My Data" with the specified primary key, an attribute +for each column of the ``DataFrame``, and the ``DataFrame``'s rows as records. + +Modifying a resource +-------------------- + +Certain resources can also be modified using specs. + +After getting a spec corresponding to a resource and modifying some properties, +the updated resource can be committed to Unify with the ``put`` function:: + + updated_dataset = ( + dataset.spec() + .with_description("Modified description") + .put() + ) + +Each spec class has many properties that can be changed, but refer to the +`Public Docs `_ for which properties will actually be updated in Tamr. +If an immutable property is changed in the update request, the new value will simply be ignored. From 15d05ad51407ceb266e1a30b1dfeea994bc9da77 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 16 Aug 2019 12:55:13 -0400 Subject: [PATCH 154/632] code --- .../project/attribute_mapping/resource.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tamr_unify_client/project/attribute_mapping/resource.py b/tamr_unify_client/project/attribute_mapping/resource.py index 140be650..a9f34e19 100644 --- a/tamr_unify_client/project/attribute_mapping/resource.py +++ b/tamr_unify_client/project/attribute_mapping/resource.py @@ -79,3 +79,64 @@ def __repr__(self): f"unified_dataset_name={self.unified_dataset_name!r}, " f"unified_attribute_name={self.unified_attribute_name!r})" ) + + +class AttributeMappingSpec: + def __init__(self, client, data, api_path): + self.client = client + self._data = data + self.api_path = api_path + + def from_data(self, data): + return AttributeMappingSpec(self.client, data, self.api_path) + + def with_input_attribute_id(self, new_input_attribute_id): + """:type: str""" + return self.from_data( + {**self._data, "inputAttributeId": new_input_attribute_id} + ) + + def with_relative_input_attribute_id(self, new_relative_input_attribute_id): + """:type: str""" + return self.from_data( + {**self._data, "relativeInputAttributeId": new_relative_input_attribute_id} + ) + + def with_input_dataset_name(self, new_input_dataset_name): + """:type: str""" + return self.from_data( + {**self._data, "inputDatasetName": new_input_dataset_name} + ) + + def with_input_attribute_name(self, new_input_attribute_name): + """:type: str""" + return self.from_data( + {**self._data, "inputAttributeName": new_input_attribute_name} + ) + + def with_unified_attribute_id(self, new_unified_attribute_id): + """:type: str""" + return self.from_data( + {**self._data, "unifiedAttributeId": new_unified_attribute_id} + ) + + def with_relative_unified_attribute_id(self, new_relative_unified_attribute_id): + """:type: str""" + return self.from_data( + { + **self._data, + "relativeUnifiedAttributeId": new_relative_unified_attribute_id, + } + ) + + def with_unified_dataset_name(self, new_unified_dataset_name): + """:type: str""" + return self.from_data( + {**self._data, "unifiedDatasetName": new_unified_dataset_name} + ) + + def with_unified_attribute_name(self, new_unified_attribute_name): + """:type: str""" + return self.from_data( + {**self._data, "unifiedAttributeName": new_unified_attribute_name} + ) From 0373e867e00589b86db2939a9f18459cec46bb90 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 9 Aug 2019 11:51:37 -0400 Subject: [PATCH 155/632] project builder --- tamr_unify_client/project/resource.py | 121 ++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/tamr_unify_client/project/resource.py b/tamr_unify_client/project/resource.py index d4b9126b..485313e1 100644 --- a/tamr_unify_client/project/resource.py +++ b/tamr_unify_client/project/resource.py @@ -1,3 +1,5 @@ +from copy import deepcopy + from tamr_unify_client.base_resource import BaseResource from tamr_unify_client.dataset.collection import DatasetCollection from tamr_unify_client.dataset.resource import Dataset @@ -153,6 +155,14 @@ def attribute_mappings(self): info = AttributeMappingCollection(self.client, alias) return info + def spec(self): + """Returns this project's spec. + + :return: The spec for the project. + :rtype: :class:`~tamr_unify_client.project.resource.ProjectSpec` + """ + return ProjectSpec.of(self) + def __repr__(self): return ( f"{self.__class__.__module__}." @@ -161,3 +171,114 @@ def __repr__(self): f"name={self.name!r}, " f"type={self.type!r})" ) + + +class ProjectSpec: + """A representation of the server view of a project.""" + + def __init__(self, client, data, api_path): + self.client = client + self._data = data + self.api_path = api_path + + @staticmethod + def of(resource): + """Creates a project spec from a project. + + :param resource: The existing project. + :type resource: :class:`~tamr_unify_client.project.resource.Project` + :return: The corresponding project spec. + :rtype: :class:`~tamr_unify_client.project.resource.ProjectSpec` + """ + return ProjectSpec(resource.client, deepcopy(resource._data), resource.api_path) + + def from_data(self, data): + """Creates a spec with the same client and API path as this one, but new data. + + :param data: The data for the new spec. + :type data: dict + :return: The new spec. + :rtype: :class:`~tamr_unify_client.project.resource.ProjectSpec` + """ + return ProjectSpec(self.client, data, self.api_path) + + def to_dict(self): + """Returns a version of this spec that conforms to the API representation. + + :returns: The spec's dict. + :rtype: dict + """ + return deepcopy(self._data) + + def with_name(self, new_name): + """Creates a new spec with the same properties, updating name. + + :param new_name: The new name. + :type new_name: str + :return: The new spec. + :rtype: :class:`~tamr_unify_client.project.resource.ProjectSpec` + """ + return self.from_data({**self._data, "name": new_name}) + + def with_description(self, new_description): + """Creates a new spec with the same properties, updating description. + + :param new_description: The new description. + :type new_description: str + :return: The new spec. + :rtype: :class:`~tamr_unify_client.project.resource.ProjectSpec` + """ + return self.from_data({**self._data, "description": new_description}) + + def with_type(self, new_type): + """Creates a new spec with the same properties, updating type. + + :param new_type: The new type. + :type new_type: str + :return: The new spec. + :rtype: :class:`~tamr_unify_client.project.resource.ProjectSpec` + """ + return self.from_data({**self._data, "type": new_type}) + + def with_external_id(self, new_external_id): + """Creates a new spec with the same properties, updating external ID. + + :param new_external_id: The new external ID. + :type new_external_id: str + :return: The new spec. + :rtype: :class:`~tamr_unify_client.project.resource.ProjectSpec` + """ + return self.from_data({**self._data, "externalId": new_external_id}) + + def with_unified_dataset_name(self, new_unified_dataset_name): + """Creates a new spec with the same properties, updating unified dataset name. + + :param new_unified_dataset_name: The new unified dataset name. + :type new_unified_dataset_name: str + :return: The new spec. + :rtype: :class:`~tamr_unify_client.project.resource.ProjectSpec` + """ + return self.from_data( + {**self._data, "unifiedDatasetName": new_unified_dataset_name} + ) + + def put(self): + """Commits these changes by updating the project in Tamr. + + :return: The updated project. + :rtype: :class:`~tamr_unify_client.project.resource.Project` + """ + updated_json = ( + self.client.put(self.api_path, json=self._data).successful().json() + ) + return Project.from_json(self.client, updated_json, self.api_path) + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"relative_id={self._data['relativeId']!r}, " + f"name={self._data['name']!r}, " + f"external_id={self._data['externalId']!r}, " + f"description={self._data['description']!r})" + ) From 3bbd800babdd281c7ed2727f79b07f47589aa591 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 9 Aug 2019 11:51:49 -0400 Subject: [PATCH 156/632] test project builder --- tests/unit/test_project.py | 59 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index f821ff7e..cfb9485a 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -1,3 +1,5 @@ +from functools import partial +import json from unittest import TestCase import responses @@ -130,6 +132,44 @@ def test_return_attribute_mapping(self): self.mappings_json[0]["unifiedDatasetName"], ) + @responses.activate + def test_update_project(self): + def create_callback(request, snoop): + snoop["payload"] = request.body + return 200, {}, json.dumps(self._updated_project_json) + + project_url = "http://localhost:9100/api/versioned/v1/projects/1" + snoop_dict = {} + responses.add_callback( + responses.PUT, project_url, partial(create_callback, snoop=snoop_dict) + ) + project = Project(self.tamr, self.project_json[0]) + + temp_spec = project.spec().with_name(self._updated_project_json["name"]) + new_project = ( + temp_spec.with_description(self._updated_project_json["description"]) + .with_external_id(self._updated_project_json["externalId"]) + .put() + ) + self.assertEqual(new_project.name, self._updated_project_json["name"]) + self.assertEqual( + new_project.description, self._updated_project_json["description"] + ) + self.assertEqual( + new_project.external_id, self._updated_project_json["externalId"] + ) + + self.assertEqual(json.loads(snoop_dict["payload"]), self._updated_project_json) + + self.assertEqual(project.name, self.project_json[0]["name"]) + self.assertEqual(project.description, self.project_json[0]["description"]) + self.assertEqual(project.external_id, self.project_json[0]["externalId"]) + + # test that intermediate didn't change + self.assertEqual( + temp_spec.to_dict()["description"], self.project_json[0]["description"] + ) + dataset_external_id = "1" datasets_url = f"http://localhost:9100/api/versioned/v1/datasets?filter=externalId=={dataset_external_id}" dataset_json = [ @@ -438,3 +478,22 @@ def test_return_attribute_mapping(self): "unifiedAttributeName": "given_name", }, ] + _updated_project_json = { + "id": "unify://unified-data/v1/projects/1", + "externalId": "new external ID", + "name": "Renamed!", + "description": "project 1 description is more descriptive", + "type": "DEDUP", + "unifiedDatasetName": "project 1 unified dataset", + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "project 1 created version", + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "project 1 modified version", + }, + "relativeId": "projects/1", + } From 2f717883834dff8ae2a4b8d9896ced0663787707 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 9 Aug 2019 11:51:59 -0400 Subject: [PATCH 157/632] docs and changelog --- CHANGELOG.md | 1 + docs/developer-interface.rst | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a85554a..6b49b230 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - [#222](https://github.com/Datatamer/unify-client-python/issues/222) Dataset spec to update an existing dataset - [#225](https://github.com/Datatamer/unify-client-python/issues/225) Attribute configuration spec to update an existing attribute configuration - [#223](https://github.com/Datatamer/unify-client-python/issues/223) Update an attribute with an attribute spec + - [#224](https://github.com/Datatamer/unify-client-python/issues/224) Project spec to update a project **BUG FIXES** - [#235](https://github.com/Datatamer/unify-client-python/issues/235) Making `AttributeCollection` retrieve attributes directly instead of by streaming diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index c2de47a8..e95727f4 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -246,6 +246,12 @@ Project .. autoclass:: tamr_unify_client.project.resource.Project :members: +Project Spec +^^^^^^^^^^^^ + +.. autoclass:: tamr_unify_client.project.resource.ProjectSpec + :members: + Project Collection ^^^^^^^^^^^^^^^^^^ From f1db4ff381b8c7b34856a978e9c623e20912bed7 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 15 Aug 2019 11:49:32 -0400 Subject: [PATCH 158/632] support for creating a new project --- tamr_unify_client/project/resource.py | 14 +++-- tests/unit/test_create_project.py | 79 ++++++++++++++++++++++----- 2 files changed, 75 insertions(+), 18 deletions(-) diff --git a/tamr_unify_client/project/resource.py b/tamr_unify_client/project/resource.py index 485313e1..0e1f83be 100644 --- a/tamr_unify_client/project/resource.py +++ b/tamr_unify_client/project/resource.py @@ -192,6 +192,15 @@ def of(resource): """ return ProjectSpec(resource.client, deepcopy(resource._data), resource.api_path) + @staticmethod + def new(): + """Creates a blank spec that could be used to construct a new project. + + :return: The empty spec. + :rtype: :class:`~tamr_unify_client.project.resource.ProjectSpec` + """ + return ProjectSpec(None, {}, None) + def from_data(self, data): """Creates a spec with the same client and API path as this one, but new data. @@ -277,8 +286,5 @@ def __repr__(self): return ( f"{self.__class__.__module__}." f"{self.__class__.__qualname__}(" - f"relative_id={self._data['relativeId']!r}, " - f"name={self._data['name']!r}, " - f"external_id={self._data['externalId']!r}, " - f"description={self._data['description']!r})" + f"dict={self._data})" ) diff --git a/tests/unit/test_create_project.py b/tests/unit/test_create_project.py index 7b31df1e..8ad65f28 100644 --- a/tests/unit/test_create_project.py +++ b/tests/unit/test_create_project.py @@ -1,31 +1,82 @@ +from functools import partial import json import responses from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth +from tamr_unify_client.project.resource import ProjectSpec auth = UsernamePasswordAuth("username", "password") tamr = Client(auth) +creation_spec = { + "name": "Project 1", + "description": "Mastering Project", + "type": "DEDUP", + "unifiedDatasetName": "Project 1 - Unified Dataset", + "externalId": "Project1", +} + +project_json = { + **creation_spec, + "id": "unify://unified-data/v1/projects/1", + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "project 1 created version", + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "project 1 modified version", + }, + "relativeId": "projects/1", +} + +projects_url = f"http://localhost:9100/api/versioned/v1/projects" +project_url = f"{projects_url}/1" + @responses.activate def test_create_project(): - creation_spec = { - "name": "Project 1", - "description": "Mastering Project", - "type": "DEDUP", - "unifiedDatasetName": "Project 1 - Unified Dataset", - "externalId": "Project1", - "resourceId": "1", - } + def create_callback(request, snoop): + snoop["payload"] = json.loads(request.body) + return 204, {}, json.dumps(project_json) - projects_url = f"http://localhost:9100/api/versioned/v1/projects" - project_url = f"http://localhost:9100/api/versioned/v1/projects/1" - - responses.add(responses.POST, projects_url, json=creation_spec, status=204) - responses.add(responses.GET, project_url, json=creation_spec) + snoop_dict = {} + responses.add_callback( + responses.POST, projects_url, partial(create_callback, snoop=snoop_dict) + ) + responses.add(responses.GET, project_url, json=project_json) u = tamr.projects.create(creation_spec) p = tamr.projects.by_resource_id("1") - assert print(p) == print(u) + + assert snoop_dict["payload"] == creation_spec + assert p.__repr__() == u.__repr__() + + +@responses.activate +def test_create_from_spec(): + def create_callback(request, snoop): + snoop["payload"] = json.loads(request.body) + return 204, {}, json.dumps(project_json) + + snoop_dict = {} + responses.add_callback( + responses.POST, projects_url, partial(create_callback, snoop=snoop_dict) + ) + + spec = ( + ProjectSpec.new() + .with_name(creation_spec["name"]) + .with_description(creation_spec["description"]) + .with_type(creation_spec["type"]) + .with_unified_dataset_name(creation_spec["unifiedDatasetName"]) + .with_external_id(creation_spec["externalId"]) + ) + p = tamr.projects.create(spec.to_dict()) + + assert snoop_dict["payload"] == creation_spec + assert p.relative_id == project_json["relativeId"] From 5d14b512eb743ae4719a9b75dffc28308c1f0a46 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Thu, 15 Aug 2019 16:50:47 -0400 Subject: [PATCH 159/632] dataset spec for creation --- tamr_unify_client/dataset/resource.py | 38 ++++++++++++++++-- tests/unit/test_create_dataset.py | 57 ++++++++++++++++++++------- 2 files changed, 76 insertions(+), 19 deletions(-) diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index 98c76547..752bca04 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -41,12 +41,12 @@ def version(self): @property def tags(self): """:type: list[str]""" - return self._data.get("tags") + return self._data.get("tags")[:] @property def key_attribute_names(self): """:type: list[str]""" - return self._data.get("keyAttributeNames") + return self._data.get("keyAttributeNames")[:] @property def attributes(self): @@ -465,6 +465,15 @@ def of(resource): """ return DatasetSpec(resource.client, deepcopy(resource._data), resource.api_path) + @staticmethod + def new(): + """Creates a blank spec that could be used to construct a new dataset. + + :return: The empty spec. + :rtype: :class:`~tamr_unify_client.dataset.resource.DatasetSpec` + """ + return DatasetSpec(None, {}, None) + def from_data(self, data): """Creates a spec with the same client and API path as this one, but new data. @@ -483,6 +492,16 @@ def to_dict(self): """ return deepcopy(self._data) + def with_name(self, new_name): + """Creates a new spec with the same properties, updating name. + + :param new_name: The new name. + :type new_name: str + :return: A new spec. + :rtype: :class:`~tamr_unify_client.dataset.resource.DatasetSpec` + """ + return self.from_data({**self._data, "name": new_name}) + def with_external_id(self, new_external_id): """Creates a new spec with the same properties, updating external ID. @@ -503,6 +522,18 @@ def with_description(self, new_description): """ return self.from_data({**self._data, "description": new_description}) + def with_key_attribute_names(self, new_key_attribute_names): + """Creates a new spec with the same properties, updating key attribute names. + + :param new_key_attribute_names: The new key attribute names. + :type new_key_attribute_names: list[str] + :return: A new spec. + :rtype: :class:`~tamr_unify_client.dataset.resource.DatasetSpec` + """ + return self.from_data( + {**self._data, "keyAttributeNames": new_key_attribute_names} + ) + def with_tags(self, new_tags): """Creates a new spec with the same properties, updating tags. @@ -526,6 +557,5 @@ def __repr__(self): return ( f"{self.__class__.__module__}." f"{self.__class__.__qualname__}(" - f"relative_id={self._data['relativeId']!r}, " - f"name={self._data['name']!r})" + f"dict={self._data})" ) diff --git a/tests/unit/test_create_dataset.py b/tests/unit/test_create_dataset.py index 72227970..16c0a7e0 100644 --- a/tests/unit/test_create_dataset.py +++ b/tests/unit/test_create_dataset.py @@ -8,7 +8,7 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth from tamr_unify_client.dataset.collection import CreationError - +from tamr_unify_client.dataset.resource import DatasetSpec auth = UsernamePasswordAuth("username", "password") tamr = Client(auth) @@ -16,19 +16,18 @@ @responses.activate def test_create_dataset(): - creation_spec = { - "id": "unify://unified-data/v1/datasets/1", - "name": "dataset", - "keyAttributeNames": ["F1"], - "description": "So much data in here!", - "externalId": "Dataset created with pubapi", - } + def create_callback(request, snoop): + snoop["payload"] = json.loads(request.body) + return 201, {}, json.dumps(_dataset_json) dataset_url = _datasets_url + "/1" - responses.add(responses.POST, _datasets_url, json=creation_spec, status=201) - responses.add(responses.GET, dataset_url, json=creation_spec) + snoop_dict = {} + responses.add_callback( + responses.POST, _datasets_url, partial(create_callback, snoop=snoop_dict) + ) + responses.add(responses.GET, dataset_url, json=_dataset_json) - u = tamr.datasets.create(creation_spec) + u = tamr.datasets.create(_creation_spec) p = tamr.datasets.by_resource_id("1") assert u.name == p.name @@ -139,6 +138,37 @@ def test_dataset_deletion_failure(): tamr.datasets.create_from_dataframe(_dataframe, "attribute1", "Dataset") +@responses.activate +def test_create_from_spec(): + def create_callback(request, snoop): + snoop["payload"] = json.loads(request.body) + return 201, {}, json.dumps(_dataset_json) + + snoop_dict = {} + responses.add_callback( + responses.POST, _datasets_url, partial(create_callback, snoop=snoop_dict) + ) + + spec = ( + DatasetSpec.new() + .with_name(_creation_spec["name"]) + .with_key_attribute_names(_creation_spec["keyAttributeNames"]) + .with_description(_creation_spec["description"]) + .with_external_id(_creation_spec["externalId"]) + ) + d = tamr.datasets.create(spec.to_dict()) + + assert snoop_dict["payload"] == _creation_spec + assert d.relative_id == _dataset_json["relativeId"] + + +_creation_spec = { + "name": "Dataset", + "keyAttributeNames": ["F1"], + "description": "So much data in here!", + "externalId": "Dataset created with pubapi", +} + _datasets_url = f"http://localhost:9100/api/versioned/v1/datasets" _dataset_url = _datasets_url + "/1" _attribute_url = _dataset_url + "/attributes" @@ -163,11 +193,9 @@ def test_dataset_deletion_failure(): } _dataset_json = { + **_creation_spec, "id": "unify://unified-data/v1/datasets/1", - "name": "Dataset", - "description": "", "version": "1", - "keyAttributeNames": ["attribute1"], "tags": [], "created": { "username": "admin", @@ -181,7 +209,6 @@ def test_dataset_deletion_failure(): }, "relativeId": "datasets/1", "upstreamDatasetIds": [], - "externalId": "Dataset", } _attribute_json = { From 65fa78aa6f621ad8e172158bdfe0aaceee0aa7c1 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 16 Aug 2019 11:23:09 -0400 Subject: [PATCH 160/632] category spec --- CHANGELOG.md | 1 + docs/developer-interface.rst | 6 ++ .../categorization/category/resource.py | 98 ++++++++++++++++++- tests/unit/test_taxonomy.py | 32 +++++- 4 files changed, 135 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b49b230..b0c0945b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - [#225](https://github.com/Datatamer/unify-client-python/issues/225) Attribute configuration spec to update an existing attribute configuration - [#223](https://github.com/Datatamer/unify-client-python/issues/223) Update an attribute with an attribute spec - [#224](https://github.com/Datatamer/unify-client-python/issues/224) Project spec to update a project + - [#275](https://github.com/Datatamer/unify-client-python/issues/275) Create a category with a category spec **BUG FIXES** - [#235](https://github.com/Datatamer/unify-client-python/issues/235) Making `AttributeCollection` retrieve attributes directly instead of by streaming diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index e95727f4..a292a9b5 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -63,6 +63,12 @@ Category .. autoclass:: tamr_unify_client.categorization.category.resource.Category :members: +Category Spec +""""""""""""" + +.. autoclass:: tamr_unify_client.categorization.category.resource.CategorySpec + :members: + Category Collection """"""""""""""""""" diff --git a/tamr_unify_client/categorization/category/resource.py b/tamr_unify_client/categorization/category/resource.py index a6935198..90978383 100644 --- a/tamr_unify_client/categorization/category/resource.py +++ b/tamr_unify_client/categorization/category/resource.py @@ -1,3 +1,5 @@ +from copy import deepcopy + from tamr_unify_client.base_resource import BaseResource @@ -21,7 +23,7 @@ def description(self): @property def path(self): """:type: list[str]""" - return self._data.get("path") + return self._data.get("path")[:] def parent(self): """Gets the parent Category of this one, or None if it is a tier 1 category @@ -37,6 +39,14 @@ def parent(self): else: return None + def spec(self): + """Returns this category's spec. + + :return: The spec for the category. + :rtype: :class:`~tamr_unify_client.categorization.category.resource.CategorySpec` + """ + return CategorySpec.of(self) + def __repr__(self): return ( f"{self.__class__.__module__}." @@ -46,3 +56,89 @@ def __repr__(self): f"path={'/'.join(self.path)!r}," f"description={self.description!r})" ) + + +class CategorySpec: + """A representation of the server view of a category.""" + + def __init__(self, client, data, api_path): + self.client = client + self._data = data + self.api_path = api_path + + @staticmethod + def of(resource): + """Creates a category spec from a category. + + :param resource: The existing category. + :type resource: :class:`~tamr_unify_client.categorization.category.resource.Category` + :return: The corresponding category spec. + :rtype: :class:`~tamr_unify_client.categorization.category.resource.CategorySpec` + """ + return CategorySpec( + resource.client, deepcopy(resource._data), resource.api_path + ) + + @staticmethod + def new(): + """Creates a blank spec that could be used to construct a new category. + + :return: The empty spec. + :rtype: :class:`~tamr_unify_client.categorization.category.resource.CategorySpec` + """ + return CategorySpec(None, {}, None) + + def from_data(self, data): + """Creates a spec with the same client and API path as this one, but new data. + + :param data: The data for the new spec. + :type data: dict + :return: The new spec. + :rtype: :class:`~tamr_unify_client.categorization.category.resource.CategorySpec` + """ + return CategorySpec(self.client, data, self.api_path) + + def to_dict(self): + """Returns a version of this spec that conforms to the API representation. + + :returns: The spec's dict. + :rtype: dict + """ + return deepcopy(self._data) + + def with_name(self, new_name): + """Creates a new spec with the same properties, updating name. + + :param new_name: The new name. + :type new_name: str + :return: The new spec. + :rtype: :class:`~tamr_unify_client.categorization.category.resource.CategorySpec` + """ + return self.from_data({**self._data, "name": new_name}) + + def with_description(self, new_description): + """Creates a new spec with the same properties, updating description. + + :param new_description: The new description. + :type new_description: str + :return: The new spec. + :rtype: :class:`~tamr_unify_client.categorization.category.resource.CategorySpec` + """ + return self.from_data({**self._data, "description": new_description}) + + def with_path(self, new_path): + """Creates a new spec with the same properties, updating path. + + :param new_path: The new path. + :type new_path: list[str] + :return: The new spec. + :rtype: :class:`~tamr_unify_client.categorization.category.resource.CategorySpec` + """ + return self.from_data({**self._data, "path": new_path}) + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"dict={self._data})" + ) diff --git a/tests/unit/test_taxonomy.py b/tests/unit/test_taxonomy.py index b7f35d57..ffa97e68 100644 --- a/tests/unit/test_taxonomy.py +++ b/tests/unit/test_taxonomy.py @@ -8,7 +8,7 @@ from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth from tamr_unify_client.categorization.category.collection import CategoryCollection -from tamr_unify_client.categorization.category.resource import Category +from tamr_unify_client.categorization.category.resource import Category, CategorySpec from tamr_unify_client.categorization.taxonomy import Taxonomy from tamr_unify_client.project.resource import Project @@ -65,6 +65,36 @@ def test_create(self): c = coll.create(creation_spec) self.assertEqual(alias + "/1", c.relative_id) + @responses.activate + def test_create_from_spec(self): + def create_callback(request, snoop): + snoop["payload"] = json.loads(request.body) + return 201, {}, json.dumps(self._categories_json[0]) + + post_url = ( + "http://localhost:9100/api/versioned/v1/projects/1/taxonomy/categories" + ) + snoop_dict = {} + responses.add_callback( + responses.POST, post_url, partial(create_callback, snoop=snoop_dict) + ) + + alias = "projects/1/taxonomy/categories" + coll = CategoryCollection(self.tamr, alias) + + json_spec = { + "name": self._categories_json[0]["name"], + "path": self._categories_json[0]["path"], + } + spec = ( + CategorySpec.new() + .with_name(self._categories_json[0]["name"]) + .with_path(self._categories_json[0]["path"]) + ) + coll.create(spec.to_dict()) + + self.assertEqual(snoop_dict["payload"], json_spec) + @responses.activate def test_bulk_create(self): def create_callback(request, snoop): From ac7377e452dd07416bdb3649e113f42f982c35aa Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 16 Aug 2019 10:00:29 -0400 Subject: [PATCH 161/632] create attribute and type with spec --- CHANGELOG.md | 1 + docs/developer-interface.rst | 6 ++ tamr_unify_client/attribute/resource.py | 44 ++++++++++++- tamr_unify_client/attribute/type.py | 82 +++++++++++++++++++++++++ tests/unit/test_attribute.py | 63 ++++++++++++++++++- 5 files changed, 192 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0c0945b..420faa3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - [#223](https://github.com/Datatamer/unify-client-python/issues/223) Update an attribute with an attribute spec - [#224](https://github.com/Datatamer/unify-client-python/issues/224) Project spec to update a project - [#275](https://github.com/Datatamer/unify-client-python/issues/275) Create a category with a category spec + - [#273](https://github.com/Datatamer/unify-client-python/issues/273) Attribute type spec to allow for attribute creation **BUG FIXES** - [#235](https://github.com/Datatamer/unify-client-python/issues/235) Making `AttributeCollection` retrieve attributes directly instead of by streaming diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index a292a9b5..0e09a27d 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -40,6 +40,12 @@ Attribute Type .. autoclass:: tamr_unify_client.attribute.type.AttributeType :members: +Attribute Type Spec +^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: tamr_unify_client.attribute.type.AttributeTypeSpec + :members: + SubAttribute ^^^^^^^^^^^^ .. autoclass:: tamr_unify_client.attribute.subattribute.SubAttribute diff --git a/tamr_unify_client/attribute/resource.py b/tamr_unify_client/attribute/resource.py index 022d20f5..89a1ea76 100644 --- a/tamr_unify_client/attribute/resource.py +++ b/tamr_unify_client/attribute/resource.py @@ -92,6 +92,15 @@ def of(resource): resource.client, deepcopy(resource._data), resource.api_path ) + @staticmethod + def new(): + """Creates a blank spec that could be used to construct a new attribute. + + :return: The empty spec. + :rtype: :class:`~tamr_unify_client.attribute.resource.AttributeSpec` + """ + return AttributeSpec(None, {}, None) + def from_data(self, data): """Creates a spec with the same client and API path as this one, but new data. @@ -110,6 +119,16 @@ def to_dict(self): """ return deepcopy(self._data) + def with_name(self, new_name): + """Creates a new spec with the same properties, updating name. + + :param new_name: The new name. + :type new_name: str + :return: The new spec. + :rtype: :class:`~tamr_unify_client.attribute.resource.AttributeSpec` + """ + return self.from_data({**self._data, "name": new_name}) + def with_description(self, new_description): """Creates a new spec with the same properties, updating description. @@ -120,6 +139,27 @@ def with_description(self, new_description): """ return self.from_data({**self._data, "description": new_description}) + def with_type(self, new_type): + """Creates a new spec with the same properties, updating type. + + :param new_type: The spec of the new type. + :type new_type: :class:`~tamr_unify_client.attribute.type.AttributeTypeSpec` + :return: The new spec. + :rtype: :class:`~tamr_unify_client.attribute.resource.AttributeSpec` + """ + type_spec = new_type.to_dict() + return self.from_data({**self._data, "type": type_spec}) + + def with_is_nullable(self, new_is_nullable): + """Creates a new spec with the same properties, updating is nullable. + + :param new_is_nullable: The new is nullable. + :type new_is_nullable: bool + :return: The new spec. + :rtype: :class:`~tamr_unify_client.attribute.resource.AttributeSpec` + """ + return self.from_data({**self._data, "isNullable": new_is_nullable}) + def put(self): """Commits the changes and updates the attribute in Tamr. @@ -135,7 +175,5 @@ def __repr__(self): return ( f"{self.__class__.__module__}." f"{self.__class__.__qualname__}(" - f"relative_id={self.api_path!r}, " - f"name={self._data['name']!r}, " - f"description={self._data['description']!r})" + f"dict={self._data!r})" ) diff --git a/tamr_unify_client/attribute/type.py b/tamr_unify_client/attribute/type.py index 8e16602a..dd0f28b9 100644 --- a/tamr_unify_client/attribute/type.py +++ b/tamr_unify_client/attribute/type.py @@ -1,3 +1,5 @@ +from copy import deepcopy + from tamr_unify_client.attribute.subattribute import SubAttribute @@ -33,9 +35,89 @@ def attributes(self): collection_json = self._data.get("attributes") return [SubAttribute(attr) for attr in collection_json] + def spec(self): + """Returns a spec representation of this attribute type. + + :return: The attribute type spec. + :rtype: :class:`~tamr_unify_client.attribute.type.AttributeTypeSpec` + """ + return AttributeTypeSpec.of(self) + def __repr__(self): return ( f"{self.__class__.__module__}." f"{self.__class__.__qualname__}(" f"base_type={self.base_type!r})" ) + + +class AttributeTypeSpec: + def __init__(self, data): + self._data = data + + @staticmethod + def of(resource): + """Creates an attribute type spec from an attribute type. + + :param resource: The existing attribute type. + :type resource: :class:`~tamr_unify_client.attribute.type.AttributeType` + :return: The corresponding attribute type spec. + :rtype: :class:`~tamr_unify_client.attribute.type.AttributeTypeSpec` + """ + return AttributeTypeSpec(deepcopy(resource._data)) + + @staticmethod + def new(): + """Creates a blank spec that could be used to construct a new attribute type. + + :return: The empty spec. + :rtype: :class:`~tamr_unify_client.attribute.type.AttributeTypeSpec` + """ + return AttributeTypeSpec({}) + + def to_dict(self): + """Returns a version of this spec that conforms to the API representation. + + :returns: The spec's dict. + :rtype: dict + """ + return deepcopy(self._data) + + def with_base_type(self, new_base_type): + """Creates a new spec with the same properties, updating the base type. + + :param new_base_type: The new base type. + :type new_base_type: str + :return: The new spec. + :rtype: :class:`~tamr_unify_client.attribute.type.AttributeTypeSpec` + """ + return AttributeTypeSpec({**self._data, "baseType": new_base_type}) + + def with_inner_type(self, new_inner_type): + """Creates a new spec with the same properties, updating the inner type. + + :param new_inner_type: The spec of the new inner type. + :type new_inner_type: :class:`~tamr_unify_client.attribute.type.AttributeTypeSpec` + :return: The new spec. + :rtype: :class:`~tamr_unify_client.attribute.type.AttributeTypeSpec` + """ + inner_spec = new_inner_type.to_dict() + return AttributeTypeSpec({**self._data, "innerType": inner_spec}) + + def with_attributes(self, new_attributes): + """Creates a new spec with the same properties, updating attributes. + + :param new_attributes: The specs of the new attributes. + :type new_attributes: list[:class:`~tamr_unify_client.attribute.resource.AttributeSpec`] + :return: The new spec. + :rtype: :class:`~tamr_unify_client.attribute.type.AttributeTypeSpec` + """ + attr_specs = [attr.to_dict() for attr in new_attributes] + return AttributeTypeSpec({**self._data, "attributes": attr_specs}) + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"dict={self._data!r})" + ) diff --git a/tests/unit/test_attribute.py b/tests/unit/test_attribute.py index 6534dfed..91825df6 100644 --- a/tests/unit/test_attribute.py +++ b/tests/unit/test_attribute.py @@ -6,7 +6,9 @@ import responses from tamr_unify_client import Client -from tamr_unify_client.attribute.resource import Attribute +from tamr_unify_client.attribute.collection import AttributeCollection +from tamr_unify_client.attribute.resource import Attribute, AttributeSpec +from tamr_unify_client.attribute.type import AttributeTypeSpec from tamr_unify_client.auth import UsernamePasswordAuth from tamr_unify_client.dataset.resource import Dataset @@ -128,6 +130,65 @@ def create_callback(request, snoop): temp_spec.to_dict()["description"], self._attributes_json[0]["description"] ) + @responses.activate + def test_create_from_spec(self): + def create_callback(request, snoop): + snoop["payload"] = json.loads(request.body) + return 201, {}, json.dumps(spec_json) + + spec_json = { + "name": "attr", + "isNullable": False, + "type": { + "baseType": "RECORD", + "attributes": [ + { + "name": str(i), + "isNullable": True, + "type": { + "baseType": "ARRAY", + "innerType": {"baseType": "STRING"}, + }, + } + for i in range(4) + ], + }, + } + + inner_spec = ( + AttributeSpec.new() + .with_type( + AttributeTypeSpec.new() + .with_base_type("ARRAY") + .with_inner_type(AttributeTypeSpec.new().with_base_type("STRING")) + ) + .with_is_nullable(True) + ) + attr_specs = [inner_spec.with_name(str(i)) for i in range(4)] + outer_spec = ( + AttributeTypeSpec.new().with_base_type("RECORD").with_attributes(attr_specs) + ) + spec = ( + AttributeSpec.new() + .with_name("attr") + .with_is_nullable(False) + .with_type(outer_spec) + ) + + snoop_dict = {} + rel_path = "projects/1/attributes" + base_path = "http://localhost:9100/api/versioned/v1" + responses.add_callback( + responses.POST, + f"{base_path}/{rel_path}", + partial(create_callback, snoop=snoop_dict), + ) + + collection = AttributeCollection(self.tamr, rel_path) + collection.create(spec.to_dict()) + + self.assertEqual(snoop_dict["payload"], spec_json) + _dataset_json = { "id": "unify://unified-data/v1/datasets/1", "externalId": "number 1", From d3ed4657c183a8d18f8510f2244a6d6505a4348f Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Mon, 12 Aug 2019 11:31:10 -0400 Subject: [PATCH 162/632] Test file. --- tests/unit/test_attribute_collection.py | 52 +++++++++++++++ ...test_attribute_configuration_collection.py | 16 +++++ tests/unit/test_base_collection.py | 25 ++++++++ tests/unit/test_category_collection.py | 59 +++++++++++++++++ tests/unit/test_input_datasets_collection.py | 64 +++++++++++++++++++ 5 files changed, 216 insertions(+) create mode 100644 tests/unit/test_attribute_collection.py create mode 100644 tests/unit/test_base_collection.py create mode 100644 tests/unit/test_category_collection.py create mode 100644 tests/unit/test_input_datasets_collection.py diff --git a/tests/unit/test_attribute_collection.py b/tests/unit/test_attribute_collection.py new file mode 100644 index 00000000..94f55123 --- /dev/null +++ b/tests/unit/test_attribute_collection.py @@ -0,0 +1,52 @@ +import pytest +import responses + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth + + +@pytest.fixture +def client(): + auth = UsernamePasswordAuth("username", "password") + tamr = Client(auth) + return tamr + + +@responses.activate +def test_delete_by_resource_id(client): + ds_url = url_prefix + "datasets/7" + attr_url = ds_url + "/attributes/family_role" + + responses.add(responses.GET, ds_url, json=datasets_collection[0]) + responses.add(responses.DELETE, attr_url, status=204) + + attributes = client.datasets.by_resource_id("7").attributes + response = attributes.delete_by_resource_id("family_role") + assert response.status_code == 204 + + +url_prefix = "http://localhost:9100/api/versioned/v1/" + +datasets_collection = [ + { + "id": "unify://unified-data/v1/datasets/115", + "name": "Globex_Store_Customers", + "description": "", + "version": "659", + "keyAttributeNames": ["custid"], + "tags": [], + "created": { + "username": "admin", + "time": "2019-08-02T20:11:51.643Z", + "version": "23388", + }, + "lastModified": { + "username": "admin", + "time": "2019-08-08T18:18:14.047Z", + "version": "26090", + }, + "relativeId": "datasets/115", + "upstreamDatasetIds": [], + "externalId": "05d15bfd-d709-472a-ad5a-048e3367cfab", + } +] diff --git a/tests/unit/test_attribute_configuration_collection.py b/tests/unit/test_attribute_configuration_collection.py index 6b1620d0..52fa58a3 100644 --- a/tests/unit/test_attribute_configuration_collection.py +++ b/tests/unit/test_attribute_configuration_collection.py @@ -104,6 +104,22 @@ def test_stream(self): stream_content.append(char._data) self.assertEqual(self.acc_json, stream_content) + @responses.activate + def test_delete_by_resource_id(self): + attr_config_url = self._base + "projects/1/attributeConfigurations/20" + + responses.add(responses.GET, self.mastering_project, json=self.project_json) + responses.add(responses.DELETE, attr_config_url, status=204) + + attr_config_collection = self.tamr.projects.by_resource_id( + "1" + ).attribute_configurations() + response = attr_config_collection.delete_by_resource_id("20") + self.assertEqual(response.status_code, 204) + + _base = "http://localhost:9100/api/versioned/v1/" + mastering_project = _base + "projects/1" + create_json = { "similarityFunction": "ABSOLUTE_DIFF", "enabledForMl": False, diff --git a/tests/unit/test_base_collection.py b/tests/unit/test_base_collection.py new file mode 100644 index 00000000..6773b824 --- /dev/null +++ b/tests/unit/test_base_collection.py @@ -0,0 +1,25 @@ +import pytest +import responses + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth + + +@pytest.fixture +def client(): + auth = UsernamePasswordAuth("username", "password") + tamr = Client(auth) + return tamr + + +@responses.activate +def test_delete_by_resource_id(client): + ds_url = url_prefix + "datasets/115" + + responses.add(responses.DELETE, ds_url, status=204) + + response = client.datasets.delete_by_resource_id("115") + assert response.status_code == 204 + + +url_prefix = "http://localhost:9100/api/versioned/v1/" diff --git a/tests/unit/test_category_collection.py b/tests/unit/test_category_collection.py new file mode 100644 index 00000000..d69dc489 --- /dev/null +++ b/tests/unit/test_category_collection.py @@ -0,0 +1,59 @@ +import pytest +import responses + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth + + +@pytest.fixture +def client(): + auth = UsernamePasswordAuth("username", "password") + tamr = Client(auth) + return tamr + + +@responses.activate +def test_delete_by_resource_id(client): + taxonomy_url = categorization_project + "/taxonomy" + category_url = taxonomy_url + "/categories/3" + + responses.add( + responses.GET, categorization_project, json=categorization_project_config + ) + + responses.add(responses.GET, taxonomy_url, json=taxonomy) + responses.add(responses.DELETE, category_url, status=204) + + category_collection = client.projects.by_resource_id("2").as_categorization() + response = category_collection.taxonomy().categories().delete_by_resource_id("3") + assert response.status_code == 204 + + +url_prefix = "http://localhost:9100/api/versioned/v1/" +categorization_project = url_prefix + "projects/2" + +categorization_project_config = { + "id": "unify://unified-data/v1/projects/2", + "name": "cat", + "description": "Categorization Project", + "type": "CATEGORIZATION", + "unifiedDatasetName": "", + "relativeId": "projects/2", + "externalId": "904bf89e-74ba-45c5-8b4a-5ff913728f66", +} + +taxonomy = { + "id": "unify://unified-data/v1/projects/2/taxonomy", + "name": "tax", + "created": { + "username": "admin", + "time": "2019-07-12T13:09:14.981Z", + "version": "405", + }, + "lastModified": { + "username": "admin", + "time": "2019-07-12T13:09:14.981Z", + "version": "405", + }, + "relativeId": "projects/2/taxonomy", +} diff --git a/tests/unit/test_input_datasets_collection.py b/tests/unit/test_input_datasets_collection.py new file mode 100644 index 00000000..87b1492e --- /dev/null +++ b/tests/unit/test_input_datasets_collection.py @@ -0,0 +1,64 @@ +import pytest +import responses + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth + + +@pytest.fixture +def client(): + auth = UsernamePasswordAuth("username", "password") + tamr = Client(auth) + return tamr + + +@responses.activate +def test_delete_by_resource_id(client): + input_collection = url_prefix + "projects/1/inputDatasets" + input_ds = input_collection + "/6" + + responses.add(responses.GET, mastering_project, json=mastering_project_config) + + responses.add(responses.GET, input_collection, json=input_ds_json) + responses.add(responses.DELETE, input_ds, status=204) + + input_ds_collection = client.projects.by_resource_id("1").input_datasets() + response = input_ds_collection.delete_by_resource_id("6") + assert response.status_code == 204 + + +url_prefix = "http://localhost:9100/api/versioned/v1/" +mastering_project = url_prefix + "projects/1" + +mastering_project_config = { + "name": "Project 1", + "description": "Mastering Project", + "type": "DEDUP", + "unifiedDatasetName": "Project 1 - Unified Dataset", + "externalId": "Project1", + "resourceId": "1", +} + +input_ds_json = [ + { + "id": "unify://unified-data/v1/datasets/6", + "name": "febrl_sample_2k.csv", + "description": "charlotte SM dataset", + "version": "5", + "keyAttributeNames": ["rec_id"], + "tags": [], + "created": { + "username": "admin", + "time": "2019-06-05T16:16:31.964Z", + "version": "35", + }, + "lastModified": { + "username": "admin", + "time": "2019-07-19T17:44:42.369Z", + "version": "22919", + }, + "relativeId": "datasets/6", + "upstreamDatasetIds": [], + "externalId": "febrl_sample_2k.csv", + } +] From f73e97377baf79b514165482574050648fe7c9b5 Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Mon, 12 Aug 2019 14:27:48 -0400 Subject: [PATCH 163/632] Changelog. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 420faa3f..656a47b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - [#224](https://github.com/Datatamer/unify-client-python/issues/224) Project spec to update a project - [#275](https://github.com/Datatamer/unify-client-python/issues/275) Create a category with a category spec - [#273](https://github.com/Datatamer/unify-client-python/issues/273) Attribute type spec to allow for attribute creation + - [#219](https://github.com/Datatamer/tamr-client/issues/219) Delete a resource from collection. **BUG FIXES** - [#235](https://github.com/Datatamer/unify-client-python/issues/235) Making `AttributeCollection` retrieve attributes directly instead of by streaming From 8aa927f87e2262b54909068140ed5358b5d035e3 Mon Sep 17 00:00:00 2001 From: Nelson Andrade Date: Mon, 12 Aug 2019 11:32:08 -0400 Subject: [PATCH 164/632] Delete by resource_id feature. --- tamr_unify_client/base_collection.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tamr_unify_client/base_collection.py b/tamr_unify_client/base_collection.py index c88cf139..780359be 100644 --- a/tamr_unify_client/base_collection.py +++ b/tamr_unify_client/base_collection.py @@ -102,6 +102,18 @@ def by_external_id(self, resource_class, external_id): return items[0] + def delete_by_resource_id(self, resource_id): + """Deletes a resource from this collection by resource_id. + + :param resource_id: the resource_id of the resource that will be deleted. + :type: str + :return: HTTP response from the server. + :rtype: :class: `requests.Response` + """ + path = f"{self.api_path}/{resource_id}" + response = self.client.delete(path).successful() + return response + def __repr__(self): return ( f"{self.__class__.__module__}." From f94b04a6e927e87c068003d8d78692480c1c647f Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 16 Aug 2019 13:31:11 -0400 Subject: [PATCH 165/632] attribute mapping spec --- CHANGELOG.md | 1 + docs/developer-interface.rst | 6 + .../project/attribute_mapping/resource.py | 133 +++++++++++++++--- .../unit/test_attribute_mapping_collection.py | 46 +++++- 4 files changed, 161 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 656a47b7..f7ddee7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - [#275](https://github.com/Datatamer/unify-client-python/issues/275) Create a category with a category spec - [#273](https://github.com/Datatamer/unify-client-python/issues/273) Attribute type spec to allow for attribute creation - [#219](https://github.com/Datatamer/tamr-client/issues/219) Delete a resource from collection. + - [#277](https://github.com/Datatamer/unify-client-python/issues/277) Attribute mapping spec **BUG FIXES** - [#235](https://github.com/Datatamer/unify-client-python/issues/235) Making `AttributeCollection` retrieve attributes directly instead of by streaming diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index 0e09a27d..05d2f9c4 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -246,6 +246,12 @@ Attribute Mapping .. autoclass:: tamr_unify_client.project.attribute_mapping.resource.AttributeMapping :members: +Attribute Mapping Spec +"""""""""""""""""""""" + +.. autoclass:: tamr_unify_client.project.attribute_mapping.resource.AttributeMappingSpec + :members: + Attribute Mapping Collection """""""""""""""""""""""""""" diff --git a/tamr_unify_client/project/attribute_mapping/resource.py b/tamr_unify_client/project/attribute_mapping/resource.py index a9f34e19..b2c19860 100644 --- a/tamr_unify_client/project/attribute_mapping/resource.py +++ b/tamr_unify_client/project/attribute_mapping/resource.py @@ -1,3 +1,6 @@ +from copy import deepcopy + + class AttributeMapping: """see https://docs.tamr.com/reference#retrieve-projects-mappings AttributeMapping and AttributeMappingCollection do not inherit from BaseResource and BaseCollection. @@ -64,6 +67,14 @@ def resource_id(self): spliced = self.relative_id.split("attributeMappings/")[1] return spliced + def spec(self): + """Returns a spec representation of this attribute mapping. + + :return: The attribute mapping spec. + :rtype: :class:`~tamr_unify_client.project.attribute_mapping.resource.AttributeMappingSpec` + """ + return AttributeMappingSpec.of(self) + def __repr__(self): return ( f"{self.__class__.__module__}." @@ -82,47 +93,108 @@ def __repr__(self): class AttributeMappingSpec: - def __init__(self, client, data, api_path): - self.client = client + """A representation of the server view of an attribute mapping""" + + def __init__(self, data): self._data = data - self.api_path = api_path - def from_data(self, data): - return AttributeMappingSpec(self.client, data, self.api_path) + @staticmethod + def of(resource): + """Creates an attribute mapping spec from a attribute mapping. + + :param resource: The existing attribute mapping. + :type resource: :class:`~tamr_unify_client.project.attribute_mapping.resource.AttributeMapping` + :return: The corresponding attribute mapping spec. + :rtype: :class:`~tamr_unify_client.project.attribute_mapping.resource.AttributeMappingSpec` + """ + return AttributeMappingSpec(deepcopy(resource._data)) + + @staticmethod + def new(): + """Creates a blank spec that could be used to construct a new attribute mapping. + + :return: The empty spec. + :rtype: :class:`~tamr_unify_client.project.attribute_mapping.resource.AttributeMappingSpec` + """ + return AttributeMappingSpec({}) + + def to_dict(self): + """Returns a version of this spec that conforms to the API representation. + + :returns: The spec's dict. + :rtype: dict + """ + return deepcopy(self._data) def with_input_attribute_id(self, new_input_attribute_id): - """:type: str""" - return self.from_data( + """Creates a new spec with the same properties, updating the input attribute id. + + :param new_input_attribute_id: The new input attribute id. + :type new_input_attribute_id: str + :return: The new spec. + :rtype: :class:`~tamr_unify_client.project.attribute_mapping.resource.AttributeMappingSpec` + """ + return AttributeMappingSpec( {**self._data, "inputAttributeId": new_input_attribute_id} ) def with_relative_input_attribute_id(self, new_relative_input_attribute_id): - """:type: str""" - return self.from_data( + """Creates a new spec with the same properties, updating the relative input attribute id. + + :param new_relative_input_attribute_id: The new relative input attribute Id. + :type new_relative_input_attribute_id: str + :return: The new spec. + :rtype: :class:`~tamr_unify_client.project.attribute_mapping.resource.AttributeMappingSpec` + """ + return AttributeMappingSpec( {**self._data, "relativeInputAttributeId": new_relative_input_attribute_id} ) def with_input_dataset_name(self, new_input_dataset_name): - """:type: str""" - return self.from_data( + """Creates a new spec with the same properties, updating the input dataset name. + + :param new_input_dataset_name: The new input dataset name. + :type new_input_dataset_name: str + :return: The new spec. + :rtype: :class:`~tamr_unify_client.project.attribute_mapping.resource.AttributeMappingSpec` + """ + return AttributeMappingSpec( {**self._data, "inputDatasetName": new_input_dataset_name} ) def with_input_attribute_name(self, new_input_attribute_name): - """:type: str""" - return self.from_data( + """Creates a new spec with the same properties, updating the input attribute name. + + :param new_input_attribute_name: The new input attribute name. + :type new_input_attribute_name: str + :return: The new spec. + :rtype: :class:`~tamr_unify_client.project.attribute_mapping.resource.AttributeMappingSpec` + """ + return AttributeMappingSpec( {**self._data, "inputAttributeName": new_input_attribute_name} ) def with_unified_attribute_id(self, new_unified_attribute_id): - """:type: str""" - return self.from_data( + """Creates a new spec with the same properties, updating the unified attribute id. + + :param new_unified_attribute_id: The new unified attribute id. + :type new_unified_attribute_id: str + :return: The new spec. + :rtype: :class:`~tamr_unify_client.project.attribute_mapping.resource.AttributeMappingSpec` + """ + return AttributeMappingSpec( {**self._data, "unifiedAttributeId": new_unified_attribute_id} ) def with_relative_unified_attribute_id(self, new_relative_unified_attribute_id): - """:type: str""" - return self.from_data( + """Creates a new spec with the same properties, updating the relative unified attribute id. + + :param new_relative_unified_attribute_id: The new relative unified attribute id. + :type new_relative_unified_attribute_id: str + :return: The new spec. + :rtype: :class:`~tamr_unify_client.project.attribute_mapping.resource.AttributeMappingSpec` + """ + return AttributeMappingSpec( { **self._data, "relativeUnifiedAttributeId": new_relative_unified_attribute_id, @@ -130,13 +202,32 @@ def with_relative_unified_attribute_id(self, new_relative_unified_attribute_id): ) def with_unified_dataset_name(self, new_unified_dataset_name): - """:type: str""" - return self.from_data( + """Creates a new spec with the same properties, updating the unified dataset name. + + :param new_unified_dataset_name: The new unified dataset name. + :type new_unified_dataset_name: str + :return: The new spec. + :rtype: :class:`~tamr_unify_client.project.attribute_mapping.resource.AttributeMappingSpec` + """ + return AttributeMappingSpec( {**self._data, "unifiedDatasetName": new_unified_dataset_name} ) def with_unified_attribute_name(self, new_unified_attribute_name): - """:type: str""" - return self.from_data( + """Creates a new spec with the same properties, updating the unified attribute name. + + :param new_unified_attribute_name: The new unified attribute name. + :type new_unified_attribute_name: str + :return: The new spec. + :rtype: :class:`~tamr_unify_client.project.attribute_mapping.resource.AttributeMappingSpec` + """ + return AttributeMappingSpec( {**self._data, "unifiedAttributeName": new_unified_attribute_name} ) + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"dict={self._data})" + ) diff --git a/tests/unit/test_attribute_mapping_collection.py b/tests/unit/test_attribute_mapping_collection.py index 5911cd94..5acfdb42 100644 --- a/tests/unit/test_attribute_mapping_collection.py +++ b/tests/unit/test_attribute_mapping_collection.py @@ -9,6 +9,7 @@ from tamr_unify_client.project.attribute_mapping.collection import ( AttributeMappingCollection, ) +from tamr_unify_client.project.attribute_mapping.resource import AttributeMappingSpec class TestAttributeMappingCollection(TestCase): @@ -57,19 +58,56 @@ def create_callback(request, snoop): self.assertEqual(test.input_dataset_name, self.create_json["inputDatasetName"]) self.assertEqual(json.loads(snoop_dict["payload"]), self.create_json) + @responses.activate + def test_create_from_spec(self): + def create_callback(request, snoop): + snoop["payload"] = json.loads(request.body) + return 200, {}, json.dumps(self.mappings_json[0]) + + url = "http://localhost:9100/api/versioned/v1/projects/4/attributeMappings" + responses.add(responses.GET, url, json=self.mappings_json) + snoop_dict = {} + responses.add_callback( + responses.POST, url, partial(create_callback, snoop=snoop_dict) + ) + + map_collection = AttributeMappingCollection( + self.tamr, "projects/4/attributeMappings" + ) + spec = ( + AttributeMappingSpec.new() + .with_relative_input_attribute_id( + self.create_json["relativeInputAttributeId"] + ) + .with_input_dataset_name(self.create_json["inputDatasetName"]) + .with_input_attribute_name(self.create_json["inputAttributeName"]) + .with_relative_unified_attribute_id( + self.create_json["relativeUnifiedAttributeId"] + ) + .with_unified_dataset_name(self.create_json["unifiedDatasetName"]) + .with_unified_attribute_name(self.create_json["unifiedAttributeName"]) + ) + map_collection.create(spec.to_dict()) + + self.assertEqual(snoop_dict["payload"], self.create_json) + create_json = { - "id": "unify://unified-data/v1/projects/1/attributeMappings/19594-14", - "relativeId": "projects/1/attributeMappings/19594-14", - "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/suburb", "relativeInputAttributeId": "datasets/6/attributes/suburb", "inputDatasetName": "febrl_sample_2k.csv", "inputAttributeName": "suburb", - "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/suburb", "relativeUnifiedAttributeId": "datasets/8/attributes/suburb", "unifiedDatasetName": "Project_1_unified_dataset", "unifiedAttributeName": "suburb", } + created_json = { + **create_json, + "id": "unify://unified-data/v1/projects/1/attributeMappings/19594-14", + "relativeId": "projects/1/attributeMappings/19594-14", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/suburb", + "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/suburb", + } + mappings_json = [ { "id": "unify://unified-data/v1/projects/4/attributeMappings/19629-12", From 6a3f2a7e8ae774dbf2f9cc71185c8b173c1e261e Mon Sep 17 00:00:00 2001 From: Charlotte Moremen Date: Fri, 16 Aug 2019 13:13:40 -0400 Subject: [PATCH 166/632] Fix #226 - Update published cluster configurations with put --- CHANGELOG.md | 1 + .../published_cluster/configuration.py | 61 +++++++++++++++++++ tests/unit/test_published_clusters.py | 20 ++++++ 3 files changed, 82 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7ddee7c..291f4996 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - [#273](https://github.com/Datatamer/unify-client-python/issues/273) Attribute type spec to allow for attribute creation - [#219](https://github.com/Datatamer/tamr-client/issues/219) Delete a resource from collection. - [#277](https://github.com/Datatamer/unify-client-python/issues/277) Attribute mapping spec + - [#226](https://github.com/Datatamer/tamr-client/issues/226) Update published cluster configurations with put **BUG FIXES** - [#235](https://github.com/Datatamer/unify-client-python/issues/235) Making `AttributeCollection` retrieve attributes directly instead of by streaming diff --git a/tamr_unify_client/mastering/published_cluster/configuration.py b/tamr_unify_client/mastering/published_cluster/configuration.py index 8af63749..50f9078a 100644 --- a/tamr_unify_client/mastering/published_cluster/configuration.py +++ b/tamr_unify_client/mastering/published_cluster/configuration.py @@ -1,3 +1,5 @@ +from copy import deepcopy + from tamr_unify_client.base_resource import BaseResource @@ -35,6 +37,13 @@ def versions_time_to_live(self): """:type: str""" return self._data.get("versionsTimeToLive") + def spec(self): + """Returns a spec representation of this published cluster + + :return: the published cluster spec + :rtype: :class`~tamr_unify_client.mastering.published_cluster.configuration.PublishedClusterConfigurationSpec""" + return PublishedClusterConfigurationSpec.of(self) + def __repr__(self): return ( f"{self.__class__.__module__}." @@ -42,3 +51,55 @@ def __repr__(self): f"relative_id={self.relative_id!r}, " f"versions_time_to_live={self.versions_time_to_live!r})" ) + + +class PublishedClusterConfigurationSpec: + """A representation of the server view of a published cluster.""" + + def __init__(self, client, data, api_path): + self.client = client + self._data = data + self.api_path = api_path + + @staticmethod + def of(resource): + """Creates an published cluster spec from published cluster. + + :param resource: The existing published cluster. + :type resource: :class:`~tamr_unify_client.mastering.published_cluster.configuration.PublishedClusterConfiguration` + :return: The corresponding published cluster spec. + :rtype: :class:`~tamr_unify_client.mastering.published_cluster.configuration.PublishedClusterConfigurationSpec` + """ + return PublishedClusterConfigurationSpec( + resource.client, deepcopy(resource._data), resource.api_path + ) + + def from_data(self, data): + """Creates a spec with new data. + + :param data: The data for the new spec. + :type data: dict + :return: The new spec. + :rtype: :class:`~tamr_unify_client.mastering.published_cluster.configuration.PublishedClusterConfigurationSpec` + """ + return PublishedClusterConfigurationSpec(self.client, data, self.api_path) + + def to_dict(self): + """Returns a version of this spec that conforms to the API representation. + + :returns: The spec's dict. + :rtype: dict + """ + return deepcopy(self._data) + + def with_versions_time_to_live(self, new_versions_time_to_live): + """Creates a new spec with the same properties, updating versions time to live. + + :param new_versions_time_to_live: The new versions time to live. + :type new_versions_time_to_live: str + :return: A new spec. + :rtype: :class:`~tamr_unify_client.mastering.published_cluster.configuration.PublishedClusterConfigurationSpec` + """ + return self.from_data( + {**self._data, "versionsTimeToLive": new_versions_time_to_live} + ) diff --git a/tests/unit/test_published_clusters.py b/tests/unit/test_published_clusters.py index e6a28fa9..7d3b64f8 100644 --- a/tests/unit/test_published_clusters.py +++ b/tests/unit/test_published_clusters.py @@ -1,3 +1,5 @@ +from functools import partial +import json from unittest import TestCase from requests import HTTPError @@ -70,6 +72,22 @@ def test_delete_published_clusters_configuration(self): config = p.published_clusters_configuration() self.assertRaises(HTTPError, config.delete) + @responses.activate + def test_update_published_clusters_configuration(self): + def create_callback(request, snoop): + snoop["payload"] = request.body + return 200, {}, json.dumps(self.update_info) + + url = "http://localhost/api/versioned/v1/projects/1/publishedClustersConfiguration" + snoop_dict = {} + responses.add(responses.GET, url, self._config_json) + responses.add_callback( + responses.PUT, url, partial(create_callback, snoop=snoop_dict) + ) + clusters = PublishedClustersConfiguration(self.tamr, self._config_json) + new_cluster = clusters.spec().with_versions_time_to_live(self.update_info) + self.assertEqual(new_cluster._data, {"versionsTimeToLive": "PT100H"}) + @responses.activate def test_refresh_ids(self): unified_dataset_url = f"{self._base_url}/projects/1/unifiedDataset" @@ -208,3 +226,5 @@ def test_refresh_stats(self): } _config_json = {"versionsTimeToLive": "P4D"} + + update_info = "PT100H" From 964a601f5c9d43fb3e3188e72a2469c0c0d3490e Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 20 Aug 2019 11:13:57 -0400 Subject: [PATCH 167/632] cascading dataset delete --- CHANGELOG.md | 1 + tamr_unify_client/dataset/collection.py | 16 ++ tamr_unify_client/dataset/resource.py | 13 ++ tests/unit/test_dataset.py | 208 ++++++++++-------- ...llection.py => test_dataset_collection.py} | 10 + 5 files changed, 152 insertions(+), 96 deletions(-) rename tests/unit/{test_base_collection.py => test_dataset_collection.py} (65%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 291f4996..99c8b24f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - [#219](https://github.com/Datatamer/tamr-client/issues/219) Delete a resource from collection. - [#277](https://github.com/Datatamer/unify-client-python/issues/277) Attribute mapping spec - [#226](https://github.com/Datatamer/tamr-client/issues/226) Update published cluster configurations with put + - [#246](https://github.com/Datatamer/tamr-client/issues/246) Cascading dataset delete **BUG FIXES** - [#235](https://github.com/Datatamer/unify-client-python/issues/235) Making `AttributeCollection` retrieve attributes directly instead of by streaming diff --git a/tamr_unify_client/dataset/collection.py b/tamr_unify_client/dataset/collection.py index 139baee3..3119dc8d 100644 --- a/tamr_unify_client/dataset/collection.py +++ b/tamr_unify_client/dataset/collection.py @@ -79,6 +79,22 @@ def by_name(self, dataset_name): return dataset raise KeyError(f"No dataset found with name: {dataset_name}") + def delete_by_resource_id(self, resource_id, cascade=False): + """Deletes a dataset from this collection by resource_id. Optionally deletes all derived datasets as well. + + :param resource_id: The resource id of the dataset in this collection to delete. + :type resource_id: str + :param cascade: Whether to delete all datasets derived from the deleted one. Optional, default is `False`. + Do not use this option unless you are certain you need it as it can have unindended consequences. + :type cascade: bool + :return: HTTP response from the server. + :rtype: :class:`requests.Response` + """ + params = {"cascade": cascade} + path = f"{self.api_path}/{resource_id}" + response = self.client.delete(path, params=params).successful() + return response + def create(self, creation_spec): """ Create a Dataset in Tamr diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index 752bca04..25daa6a3 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -272,6 +272,19 @@ def spec(self): """ return DatasetSpec.of(self) + def delete(self, cascade=False): + """Deletes this dataset, optionally deleting all derived datasets as well. + + :param cascade: Whether to delete all datasets derived from this one. Optional, default is `False`. + Do not use this option unless you are certain you need it as it can have unindended consequences. + :type cascade: bool + :return: HTTP response from the server + :rtype: :class:`requests.Response` + """ + params = {"cascade": cascade} + response = self.client.delete(self.api_path, params=params).successful() + return response + @property def __geo_interface__(self): """Retrieve a representation of this dataset that conforms to the Python Geo Interface. diff --git a/tests/unit/test_dataset.py b/tests/unit/test_dataset.py index 85f20a88..234b356e 100644 --- a/tests/unit/test_dataset.py +++ b/tests/unit/test_dataset.py @@ -1,7 +1,7 @@ from functools import partial import json -from unittest import TestCase +import pytest from requests import HTTPError import responses @@ -9,98 +9,114 @@ from tamr_unify_client.auth import UsernamePasswordAuth -class TestAttribute(TestCase): - def setUp(self): - auth = UsernamePasswordAuth("username", "password") - self.tamr = Client(auth) - - @responses.activate - def test_delete(self): - responses.add(responses.GET, self._url, json=self._dataset_json) - responses.add(responses.DELETE, self._url, status=204) - responses.add(responses.GET, self._url, status=404) - - dataset = self.tamr.datasets.by_resource_id("1") - self.assertEqual(dataset._data, self._dataset_json) - - response = dataset.delete() - self.assertEqual(response.status_code, 204) - self.assertRaises(HTTPError, lambda: self.tamr.datasets.by_resource_id("1")) - - @responses.activate - def test_update(self): - def create_callback(request, snoop): - snoop["payload"] = request.body - return 200, {}, json.dumps(self._updated_dataset_json) - - snoop_dict = {} - responses.add(responses.GET, self._url, json=self._dataset_json) - responses.add_callback( - responses.PUT, self._url, partial(create_callback, snoop=snoop_dict) - ) - - dataset = self.tamr.datasets.by_resource_id("1") - - temp_spec = dataset.spec().with_description( - self._updated_dataset_json["description"] - ) - new_dataset = ( - temp_spec.with_external_id(self._updated_dataset_json["externalId"]) - .with_tags(self._updated_dataset_json["tags"]) - .put() - ) - - self.assertEqual(new_dataset._data, self._updated_dataset_json) - self.assertEqual(json.loads(snoop_dict["payload"]), self._updated_dataset_json) - self.assertEqual(dataset._data, self._dataset_json) - - # checking that intermediate didn't change - self.assertEqual( - temp_spec.to_dict()["externalId"], self._dataset_json["externalId"] - ) - - _url = "http://localhost:9100/api/versioned/v1/datasets/1" - - _dataset_json = { - "id": "unify://unified-data/v1/datasets/1", - "externalId": "1", - "name": "dataset 1 name", - "description": "dataset 1 description", - "version": "dataset 1 version", - "keyAttributeNames": ["tamr_id"], - "tags": [], - "created": { - "username": "admin", - "time": "2018-09-10T16:06:20.636Z", - "version": "dataset 1 created version", - }, - "lastModified": { - "username": "admin", - "time": "2018-09-10T16:06:20.851Z", - "version": "dataset 1 modified version", - }, - "relativeId": "datasets/1", - "upstreamDatasetIds": [], - } - - _updated_dataset_json = { - "id": "unify://unified-data/v1/datasets/1", - "externalId": "dataset1", - "name": "dataset 1 name", - "description": "updated description", - "version": "dataset 1 version", - "keyAttributeNames": ["tamr_id"], - "tags": ["new", "tags"], - "created": { - "username": "admin", - "time": "2018-09-10T16:06:20.636Z", - "version": "dataset 1 created version", - }, - "lastModified": { - "username": "admin", - "time": "2018-09-10T16:06:20.851Z", - "version": "dataset 1 modified version", - }, - "relativeId": "datasets/1", - "upstreamDatasetIds": [], - } +@pytest.fixture +def client(): + auth = UsernamePasswordAuth("username", "password") + tamr = Client(auth) + return tamr + + +@responses.activate +def test_delete(client): + responses.add(responses.GET, _url, json=_dataset_json) + responses.add(responses.DELETE, _url, status=204) + responses.add(responses.GET, _url, status=404) + + dataset = client.datasets.by_resource_id("1") + assert dataset._data == _dataset_json + + response = dataset.delete() + assert response.status_code == 204 + with pytest.raises(HTTPError): + client.datasets.by_resource_id("1") + + +@responses.activate +def test_cascading_delete(client): + responses.add(responses.GET, _url, json=_dataset_json) + responses.add(responses.DELETE, _url + "?cascade=True", status=204) + responses.add(responses.GET, _url, status=404) + + dataset = client.datasets.by_resource_id("1") + assert dataset._data == _dataset_json + + response = dataset.delete(cascade=True) + assert response.status_code == 204 + with pytest.raises(HTTPError): + client.datasets.by_resource_id("1") + + +@responses.activate +def test_update(client): + def create_callback(request, snoop): + snoop["payload"] = request.body + return 200, {}, json.dumps(_updated_dataset_json) + + snoop_dict = {} + responses.add(responses.GET, _url, json=_dataset_json) + responses.add_callback( + responses.PUT, _url, partial(create_callback, snoop=snoop_dict) + ) + + dataset = client.datasets.by_resource_id("1") + + temp_spec = dataset.spec().with_description(_updated_dataset_json["description"]) + new_dataset = ( + temp_spec.with_external_id(_updated_dataset_json["externalId"]) + .with_tags(_updated_dataset_json["tags"]) + .put() + ) + + assert new_dataset._data == _updated_dataset_json + assert json.loads(snoop_dict["payload"]) == _updated_dataset_json + assert dataset._data == _dataset_json + + # checking that intermediate didn't change + assert temp_spec.to_dict()["externalId"] == _dataset_json["externalId"] + + +_url = "http://localhost:9100/api/versioned/v1/datasets/1" + +_dataset_json = { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "1", + "name": "dataset 1 name", + "description": "dataset 1 description", + "version": "dataset 1 version", + "keyAttributeNames": ["tamr_id"], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version", + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version", + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [], +} + +_updated_dataset_json = { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "dataset1", + "name": "dataset 1 name", + "description": "updated description", + "version": "dataset 1 version", + "keyAttributeNames": ["tamr_id"], + "tags": ["new", "tags"], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version", + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version", + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [], +} diff --git a/tests/unit/test_base_collection.py b/tests/unit/test_dataset_collection.py similarity index 65% rename from tests/unit/test_base_collection.py rename to tests/unit/test_dataset_collection.py index 6773b824..945faaf9 100644 --- a/tests/unit/test_base_collection.py +++ b/tests/unit/test_dataset_collection.py @@ -22,4 +22,14 @@ def test_delete_by_resource_id(client): assert response.status_code == 204 +@responses.activate +def test_delete_by_resource_id_cascade(client): + ds_url = url_prefix + "datasets/115?cascade=True" + + responses.add(responses.DELETE, ds_url, status=204) + + response = client.datasets.delete_by_resource_id("115", cascade=True) + assert response.status_code == 204 + + url_prefix = "http://localhost:9100/api/versioned/v1/" From ab5d8cb9ea43ba72be5abe5499a2ad1d14d17038 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 20 Aug 2019 11:56:22 -0400 Subject: [PATCH 168/632] published cluster configuration put --- .../published_cluster/configuration.py | 51 +++++++++++++------ tests/unit/test_published_clusters.py | 11 ++-- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/tamr_unify_client/mastering/published_cluster/configuration.py b/tamr_unify_client/mastering/published_cluster/configuration.py index 50f9078a..bed402c7 100644 --- a/tamr_unify_client/mastering/published_cluster/configuration.py +++ b/tamr_unify_client/mastering/published_cluster/configuration.py @@ -38,11 +38,12 @@ def versions_time_to_live(self): return self._data.get("versionsTimeToLive") def spec(self): - """Returns a spec representation of this published cluster + """Returns a spec representation of this published cluster configuration. - :return: the published cluster spec - :rtype: :class`~tamr_unify_client.mastering.published_cluster.configuration.PublishedClusterConfigurationSpec""" - return PublishedClusterConfigurationSpec.of(self) + :return: The published cluster configuration spec. + :rtype: :class`~tamr_unify_client.mastering.published_cluster.configuration.PublishedClustersConfigurationSpec` + """ + return PublishedClustersConfigurationSpec.of(self) def __repr__(self): return ( @@ -53,8 +54,8 @@ def __repr__(self): ) -class PublishedClusterConfigurationSpec: - """A representation of the server view of a published cluster.""" +class PublishedClustersConfigurationSpec: + """A representation of the server view of published clusters configuration.""" def __init__(self, client, data, api_path): self.client = client @@ -63,14 +64,14 @@ def __init__(self, client, data, api_path): @staticmethod def of(resource): - """Creates an published cluster spec from published cluster. + """Creates an published cluster configuration spec from published cluster configuration. - :param resource: The existing published cluster. - :type resource: :class:`~tamr_unify_client.mastering.published_cluster.configuration.PublishedClusterConfiguration` - :return: The corresponding published cluster spec. - :rtype: :class:`~tamr_unify_client.mastering.published_cluster.configuration.PublishedClusterConfigurationSpec` + :param resource: The existing published cluster configuration. + :type resource: :class:`~tamr_unify_client.mastering.published_cluster.configuration.PublishedClustersConfiguration` + :return: The corresponding published cluster configuration spec. + :rtype: :class:`~tamr_unify_client.mastering.published_cluster.configuration.PublishedClustersConfigurationSpec` """ - return PublishedClusterConfigurationSpec( + return PublishedClustersConfigurationSpec( resource.client, deepcopy(resource._data), resource.api_path ) @@ -80,9 +81,9 @@ def from_data(self, data): :param data: The data for the new spec. :type data: dict :return: The new spec. - :rtype: :class:`~tamr_unify_client.mastering.published_cluster.configuration.PublishedClusterConfigurationSpec` + :rtype: :class:`~tamr_unify_client.mastering.published_cluster.configuration.PublishedClustersConfigurationSpec` """ - return PublishedClusterConfigurationSpec(self.client, data, self.api_path) + return PublishedClustersConfigurationSpec(self.client, data, self.api_path) def to_dict(self): """Returns a version of this spec that conforms to the API representation. @@ -98,8 +99,28 @@ def with_versions_time_to_live(self, new_versions_time_to_live): :param new_versions_time_to_live: The new versions time to live. :type new_versions_time_to_live: str :return: A new spec. - :rtype: :class:`~tamr_unify_client.mastering.published_cluster.configuration.PublishedClusterConfigurationSpec` + :rtype: :class:`~tamr_unify_client.mastering.published_cluster.configuration.PublishedClustersConfigurationSpec` """ return self.from_data( {**self._data, "versionsTimeToLive": new_versions_time_to_live} ) + + def put(self): + """Commits these changes by updating the configuration in Tamr. + + :return: The updated configuration. + :rtype: :class:`~tamr_unify_client.mastering.published_cluster.configuration.PublishedClustersConfiguration` + """ + updated_json = ( + self.client.put(self.api_path, json=self._data).successful().json() + ) + return PublishedClustersConfiguration.from_json( + self.client, updated_json, self.api_path + ) + + def __repr__(self): + return ( + f"{self.__class__.__module__}." + f"{self.__class__.__qualname__}(" + f"dict={self._data})" + ) diff --git a/tests/unit/test_published_clusters.py b/tests/unit/test_published_clusters.py index 7d3b64f8..1ca2303c 100644 --- a/tests/unit/test_published_clusters.py +++ b/tests/unit/test_published_clusters.py @@ -76,16 +76,17 @@ def test_delete_published_clusters_configuration(self): def test_update_published_clusters_configuration(self): def create_callback(request, snoop): snoop["payload"] = request.body - return 200, {}, json.dumps(self.update_info) + return 200, {}, json.dumps({"versionsTimeToLive": self.update_info}) - url = "http://localhost/api/versioned/v1/projects/1/publishedClustersConfiguration" + path = "projects/1/publishedClustersConfiguration" + url = f"http://localhost:9100/api/versioned/v1/{path}" snoop_dict = {} - responses.add(responses.GET, url, self._config_json) responses.add_callback( responses.PUT, url, partial(create_callback, snoop=snoop_dict) ) - clusters = PublishedClustersConfiguration(self.tamr, self._config_json) - new_cluster = clusters.spec().with_versions_time_to_live(self.update_info) + + clusters = PublishedClustersConfiguration(self.tamr, self._config_json, path) + new_cluster = clusters.spec().with_versions_time_to_live(self.update_info).put() self.assertEqual(new_cluster._data, {"versionsTimeToLive": "PT100H"}) @responses.activate From 5bda65b7ab73b144918409c7da06d480e6a5e71c Mon Sep 17 00:00:00 2001 From: Charlotte Moremen Date: Fri, 16 Aug 2019 14:59:00 -0400 Subject: [PATCH 169/632] Fix #221 - Delete an attribute mapping --- CHANGELOG.md | 4 + .../project/attribute_mapping/collection.py | 15 +- .../project/attribute_mapping/resource.py | 12 +- tests/unit/test_attribute_mapping.py | 92 +++-- .../unit/test_attribute_mapping_collection.py | 329 +++++++++--------- 5 files changed, 253 insertions(+), 199 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99c8b24f..6e4179d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,7 @@ ## 0.9.0-dev + **BREAKING CHANGES** + - `AttributeMapping.__init__()` now takes arguments `client`, `data`, and `alias` (used to take only `data`). + **NEW FEATURES** - [#218](https://github.com/Datatamer/unify-client-python/issues/218) Delete a `BaseResource` - [#233](https://github.com/Datatamer/unify-client-python/issues/233) Remove an input dataset from a project @@ -13,6 +16,7 @@ - [#277](https://github.com/Datatamer/unify-client-python/issues/277) Attribute mapping spec - [#226](https://github.com/Datatamer/tamr-client/issues/226) Update published cluster configurations with put - [#246](https://github.com/Datatamer/tamr-client/issues/246) Cascading dataset delete + - [#221](https://github.com/Datatamer/unify-client-python/issues/221) delete an attribute mapping **BUG FIXES** - [#235](https://github.com/Datatamer/unify-client-python/issues/235) Making `AttributeCollection` retrieve attributes directly instead of by streaming diff --git a/tamr_unify_client/project/attribute_mapping/collection.py b/tamr_unify_client/project/attribute_mapping/collection.py index 478c78c2..31f8f7d3 100644 --- a/tamr_unify_client/project/attribute_mapping/collection.py +++ b/tamr_unify_client/project/attribute_mapping/collection.py @@ -19,7 +19,7 @@ def stream(self): """ all_maps = self.client.get(self.api_path).successful().json() for mapping in all_maps: - yield AttributeMapping(mapping) + yield AttributeMapping(self.client, mapping) def by_resource_id(self, resource_id): """Retrieve an item in this collection by resource ID. @@ -54,4 +54,15 @@ def create(self, creation_spec): :rtype: :class:`~tamr_unify_client.project.attribute_mapping.resource.AttributeMapping` """ data = self.client.post(self.api_path, json=creation_spec).successful().json() - return AttributeMapping(data) + return AttributeMapping(self.client, data) + + def delete_by_resource_id(self, resource_id): + """delete an attribute mapping using its Resource ID. + :param resource_id: the resource ID of the mapping to be deleted. + :type resource_id: str + :returns: HTTP status code + :rtype: :class:`requests.Response` + """ + path = self.api_path + "/" + resource_id + response = self.client.delete(path).successful() + return response diff --git a/tamr_unify_client/project/attribute_mapping/resource.py b/tamr_unify_client/project/attribute_mapping/resource.py index b2c19860..af2e86c9 100644 --- a/tamr_unify_client/project/attribute_mapping/resource.py +++ b/tamr_unify_client/project/attribute_mapping/resource.py @@ -8,8 +8,10 @@ class AttributeMapping: (ex: /projects/1/attributeMappings/1), but these types of URLs do not exist for attribute mappings """ - def __init__(self, data): + def __init__(self, client, data, alias=None): self._data = data + self.client = client + self.api_path = alias or self.relative_id @property def id(self): @@ -75,6 +77,14 @@ def spec(self): """ return AttributeMappingSpec.of(self) + def delete(self): + """delete this attribute mapping. + :return: HTTP response from the server + :rtype: :class `requests.Response` + """ + response = self.client.delete(self.api_path).successful() + return response + def __repr__(self): return ( f"{self.__class__.__module__}." diff --git a/tests/unit/test_attribute_mapping.py b/tests/unit/test_attribute_mapping.py index 35ac30c7..e020aa61 100644 --- a/tests/unit/test_attribute_mapping.py +++ b/tests/unit/test_attribute_mapping.py @@ -1,51 +1,71 @@ -from unittest import TestCase +import pytest +import responses from tamr_unify_client.project.attribute_mapping.resource import AttributeMapping -class TestAttributeMapping(TestCase): - def test_resource(self): - test = AttributeMapping(self.mappings_json) +@pytest.fixture +def client(): + from tamr_unify_client import Client + from tamr_unify_client.auth import UsernamePasswordAuth - expected = self.mappings_json["relativeId"] - self.assertEqual(expected, test.relative_id) + return Client(UsernamePasswordAuth("username", "password")) - expected = self.mappings_json["id"] - self.assertEqual(expected, test.id) - expected = self.mappings_json["inputAttributeId"] - self.assertEqual(expected, test.input_attribute_id) +def test_resource(client): + test = AttributeMapping(client, mappings_json) - expected = self.mappings_json["relativeInputAttributeId"] - self.assertEqual(expected, test.relative_input_attribute_id) + expected = mappings_json["relativeId"] + assert expected == test.relative_id - expected = self.mappings_json["inputDatasetName"] - self.assertEqual(expected, test.input_dataset_name) + expected = mappings_json["id"] + assert expected == test.id - expected = self.mappings_json["inputAttributeName"] - self.assertEqual(expected, test.input_attribute_name) + expected = mappings_json["inputAttributeId"] + assert expected == test.input_attribute_id - expected = self.mappings_json["unifiedAttributeId"] - self.assertEqual(expected, test.unified_attribute_id) + expected = mappings_json["relativeInputAttributeId"] + assert expected == test.relative_input_attribute_id - expected = self.mappings_json["relativeUnifiedAttributeId"] - self.assertEqual(expected, test.relative_unified_attribute_id) + expected = mappings_json["inputDatasetName"] + assert expected == test.input_dataset_name - expected = self.mappings_json["unifiedDatasetName"] - self.assertEqual(expected, test.unified_dataset_name) + expected = mappings_json["inputAttributeName"] + assert expected == test.input_attribute_name - expected = self.mappings_json["unifiedAttributeName"] - self.assertEqual(expected, test.unified_attribute_name) + expected = mappings_json["unifiedAttributeId"] + assert expected == test.unified_attribute_id - mappings_json = { - "id": "unify://unified-data/v1/projects/4/attributeMappings/19629-12", - "relativeId": "projects/4/attributeMappings/19629-12", - "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/surname", - "relativeInputAttributeId": "datasets/6/attributes/surname", - "inputDatasetName": "febrl_sample_2k.csv", - "inputAttributeName": "surname", - "unifiedAttributeId": "unify://unified-data/v1/datasets/79/attributes/surname", - "relativeUnifiedAttributeId": "datasets/79/attributes/surname", - "unifiedDatasetName": "Charlotte_unified_dataset", - "unifiedAttributeName": "surname", - } + expected = mappings_json["relativeUnifiedAttributeId"] + assert expected == test.relative_unified_attribute_id + + expected = mappings_json["unifiedDatasetName"] + assert expected == test.unified_dataset_name + + expected = mappings_json["unifiedAttributeName"] + assert expected == test.unified_attribute_name + + +@responses.activate +def test_delete(client): + specific_url = ( + "http://localhost:9100/api/versioned/v1/projects/4/attributeMappings/19629-12" + ) + responses.add(responses.DELETE, specific_url, status=204) + delete_map = AttributeMapping(client, mappings_json) + final_response = delete_map.delete() + assert final_response.status_code == 204 + + +mappings_json = { + "id": "unify://unified-data/v1/projects/4/attributeMappings/19629-12", + "relativeId": "projects/4/attributeMappings/19629-12", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/surname", + "relativeInputAttributeId": "datasets/6/attributes/surname", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "surname", + "unifiedAttributeId": "unify://unified-data/v1/datasets/79/attributes/surname", + "relativeUnifiedAttributeId": "datasets/79/attributes/surname", + "unifiedDatasetName": "Charlotte_unified_dataset", + "unifiedAttributeName": "surname", +} diff --git a/tests/unit/test_attribute_mapping_collection.py b/tests/unit/test_attribute_mapping_collection.py index 5acfdb42..9bca87d6 100644 --- a/tests/unit/test_attribute_mapping_collection.py +++ b/tests/unit/test_attribute_mapping_collection.py @@ -1,172 +1,181 @@ from functools import partial import json -from unittest import TestCase +import pytest import responses -from tamr_unify_client import Client -from tamr_unify_client.auth import UsernamePasswordAuth from tamr_unify_client.project.attribute_mapping.collection import ( AttributeMappingCollection, ) from tamr_unify_client.project.attribute_mapping.resource import AttributeMappingSpec -class TestAttributeMappingCollection(TestCase): - def setUp(self): - auth = UsernamePasswordAuth("username", "password") - self.tamr = Client(auth) - - @responses.activate - def test_by_resource_id(self): - url = "http://localhost:9100/api/versioned/v1/projects/4/attributeMappings" - responses.add(responses.GET, url, json=self.mappings_json) - tester = AttributeMappingCollection(self.tamr, url) - by_resource = tester.by_resource_id("19629-12") - self.assertEqual( - by_resource.unified_attribute_name, - self.mappings_json[0]["unifiedAttributeName"], - ) - - @responses.activate - def test_by_relative_id(self): - url = "http://localhost:9100/api/versioned/v1/projects/4/attributeMappings" - responses.add(responses.GET, url, json=self.mappings_json) - tester = AttributeMappingCollection(self.tamr, url) - by_relative = tester.by_relative_id("projects/4/attributeMappings/19629-12") - self.assertEqual( - by_relative.unified_attribute_name, - self.mappings_json[0]["unifiedAttributeName"], - ) - - @responses.activate - def test_create(self): - def create_callback(request, snoop): - snoop["payload"] = request.body - return 200, {}, json.dumps(self.mappings_json[0]) - - url = "http://localhost:9100/api/versioned/v1/projects/4/attributeMappings" - responses.add(responses.GET, url, json=self.mappings_json) - snoop_dict = {} - responses.add_callback( - responses.POST, url, partial(create_callback, snoop=snoop_dict) - ) - map_collection = AttributeMappingCollection( - self.tamr, "projects/4/attributeMappings" - ) - test = map_collection.create(self.create_json) - self.assertEqual(test.input_dataset_name, self.create_json["inputDatasetName"]) - self.assertEqual(json.loads(snoop_dict["payload"]), self.create_json) - - @responses.activate - def test_create_from_spec(self): - def create_callback(request, snoop): - snoop["payload"] = json.loads(request.body) - return 200, {}, json.dumps(self.mappings_json[0]) - - url = "http://localhost:9100/api/versioned/v1/projects/4/attributeMappings" - responses.add(responses.GET, url, json=self.mappings_json) - snoop_dict = {} - responses.add_callback( - responses.POST, url, partial(create_callback, snoop=snoop_dict) - ) - - map_collection = AttributeMappingCollection( - self.tamr, "projects/4/attributeMappings" - ) - spec = ( - AttributeMappingSpec.new() - .with_relative_input_attribute_id( - self.create_json["relativeInputAttributeId"] - ) - .with_input_dataset_name(self.create_json["inputDatasetName"]) - .with_input_attribute_name(self.create_json["inputAttributeName"]) - .with_relative_unified_attribute_id( - self.create_json["relativeUnifiedAttributeId"] - ) - .with_unified_dataset_name(self.create_json["unifiedDatasetName"]) - .with_unified_attribute_name(self.create_json["unifiedAttributeName"]) - ) - map_collection.create(spec.to_dict()) - - self.assertEqual(snoop_dict["payload"], self.create_json) - - create_json = { - "relativeInputAttributeId": "datasets/6/attributes/suburb", +@pytest.fixture +def client(): + from tamr_unify_client import Client + from tamr_unify_client.auth import UsernamePasswordAuth + + return Client(UsernamePasswordAuth("username", "password")) + + +@responses.activate +def test_by_resource_id(client): + url = "http://localhost:9100/api/versioned/v1/projects/4/attributeMappings" + responses.add(responses.GET, url, json=mappings_json) + tester = AttributeMappingCollection(client, url) + by_resource = tester.by_resource_id("19629-12") + assert ( + by_resource.unified_attribute_name == mappings_json[0]["unifiedAttributeName"] + ) + + +@responses.activate +def test_by_relative_id(client): + url = "http://localhost:9100/api/versioned/v1/projects/4/attributeMappings" + responses.add(responses.GET, url, json=mappings_json) + tester = AttributeMappingCollection(client, url) + by_relative = tester.by_relative_id("projects/4/attributeMappings/19629-12") + assert ( + by_relative.unified_attribute_name == mappings_json[0]["unifiedAttributeName"] + ) + + +@responses.activate +def test_create(client): + def create_callback(request, snoop): + snoop["payload"] = request.body + return 200, {}, json.dumps(mappings_json[0]) + + url = "http://localhost:9100/api/versioned/v1/projects/4/attributeMappings" + responses.add(responses.GET, url, json=mappings_json) + snoop_dict = {} + responses.add_callback( + responses.POST, url, partial(create_callback, snoop=snoop_dict) + ) + map_collection = AttributeMappingCollection(client, "projects/4/attributeMappings") + test = map_collection.create(create_json) + assert test.input_dataset_name == create_json["inputDatasetName"] + assert json.loads(snoop_dict["payload"]) == create_json + + +@responses.activate +def test_delete(client): + general_url = "http://localhost:9100/api/versioned/v1/projects/4/attributeMappings" + delete_collection = AttributeMappingCollection(client, general_url) + specific_url = general_url + "/19629-12" + responses.add(responses.DELETE, specific_url, status=204) + response = delete_collection.delete_by_resource_id("19629-12") + assert response.status_code == 204 + + +@responses.activate +def test_create_from_spec(client): + def create_callback(request, snoop): + snoop["payload"] = json.loads(request.body) + return 200, {}, json.dumps(mappings_json[0]) + + url = "http://localhost:9100/api/versioned/v1/projects/4/attributeMappings" + responses.add(responses.GET, url, json=mappings_json) + snoop_dict = {} + responses.add_callback( + responses.POST, url, partial(create_callback, snoop=snoop_dict) + ) + + map_collection = AttributeMappingCollection(client, "projects/4/attributeMappings") + spec = ( + AttributeMappingSpec.new() + .with_relative_input_attribute_id(create_json["relativeInputAttributeId"]) + .with_input_dataset_name(create_json["inputDatasetName"]) + .with_input_attribute_name(create_json["inputAttributeName"]) + .with_relative_unified_attribute_id(create_json["relativeUnifiedAttributeId"]) + .with_unified_dataset_name(create_json["unifiedDatasetName"]) + .with_unified_attribute_name(create_json["unifiedAttributeName"]) + ) + map_collection.create(spec.to_dict()) + + assert snoop_dict["payload"] == create_json + map_collection = AttributeMappingCollection(client, "projects/4/attributeMappings") + test = map_collection.create(create_json) + assert test.input_dataset_name == create_json["inputDatasetName"] + assert snoop_dict["payload"] == create_json + + +create_json = { + "relativeInputAttributeId": "datasets/6/attributes/suburb", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "suburb", + "relativeUnifiedAttributeId": "datasets/8/attributes/suburb", + "unifiedDatasetName": "Project_1_unified_dataset", + "unifiedAttributeName": "suburb", +} + +created_json = { + **create_json, + "id": "unify://unified-data/v1/projects/1/attributeMappings/19594-14", + "relativeId": "projects/1/attributeMappings/19594-14", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/suburb", + "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/suburb", +} + +mappings_json = [ + { + "id": "unify://unified-data/v1/projects/4/attributeMappings/19629-12", + "relativeId": "projects/4/attributeMappings/19629-12", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/surname", + "relativeInputAttributeId": "datasets/6/attributes/surname", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "surname", + "unifiedAttributeId": "unify://unified-data/v1/datasets/79/attributes/surname", + "relativeUnifiedAttributeId": "datasets/79/attributes/surname", + "unifiedDatasetName": "Charlotte_unified_dataset", + "unifiedAttributeName": "surname", + }, + { + "id": "unify://unified-data/v1/projects/4/attributeMappings/19629-17", + "relativeId": "projects/4/attributeMappings/19629-17", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/address_1", + "relativeInputAttributeId": "datasets/6/attributes/address_1", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "address_1", + "unifiedAttributeId": "unify://unified-data/v1/datasets/79/attributes/surname", + "relativeUnifiedAttributeId": "datasets/79/attributes/surname", + "unifiedDatasetName": "Charlotte_unified_dataset", + "unifiedAttributeName": "surname", + }, + { + "id": "unify://unified-data/v1/projects/4/attributeMappings/19630-16", + "relativeId": "projects/4/attributeMappings/19630-16", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/street_number", + "relativeInputAttributeId": "datasets/6/attributes/street_number", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "street_number", + "unifiedAttributeId": "unify://unified-data/v1/datasets/79/attributes/street_number", + "relativeUnifiedAttributeId": "datasets/79/attributes/street_number", + "unifiedDatasetName": "Charlotte_unified_dataset", + "unifiedAttributeName": "street_number", + }, + { + "id": "unify://unified-data/v1/projects/4/attributeMappings/19631-17", + "relativeId": "projects/4/attributeMappings/19631-17", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/address_1", + "relativeInputAttributeId": "datasets/6/attributes/address_1", + "inputDatasetName": "febrl_sample_2k.csv", + "inputAttributeName": "address_1", + "unifiedAttributeId": "unify://unified-data/v1/datasets/79/attributes/address_1", + "relativeUnifiedAttributeId": "datasets/79/attributes/address_1", + "unifiedDatasetName": "Charlotte_unified_dataset", + "unifiedAttributeName": "address_1", + }, + { + "id": "unify://unified-data/v1/projects/4/attributeMappings/19632-9", + "relativeId": "projects/4/attributeMappings/19632-9", + "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/date_of_birth", + "relativeInputAttributeId": "datasets/6/attributes/date_of_birth", "inputDatasetName": "febrl_sample_2k.csv", - "inputAttributeName": "suburb", - "relativeUnifiedAttributeId": "datasets/8/attributes/suburb", - "unifiedDatasetName": "Project_1_unified_dataset", - "unifiedAttributeName": "suburb", - } - - created_json = { - **create_json, - "id": "unify://unified-data/v1/projects/1/attributeMappings/19594-14", - "relativeId": "projects/1/attributeMappings/19594-14", - "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/suburb", - "unifiedAttributeId": "unify://unified-data/v1/datasets/8/attributes/suburb", - } - - mappings_json = [ - { - "id": "unify://unified-data/v1/projects/4/attributeMappings/19629-12", - "relativeId": "projects/4/attributeMappings/19629-12", - "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/surname", - "relativeInputAttributeId": "datasets/6/attributes/surname", - "inputDatasetName": "febrl_sample_2k.csv", - "inputAttributeName": "surname", - "unifiedAttributeId": "unify://unified-data/v1/datasets/79/attributes/surname", - "relativeUnifiedAttributeId": "datasets/79/attributes/surname", - "unifiedDatasetName": "Charlotte_unified_dataset", - "unifiedAttributeName": "surname", - }, - { - "id": "unify://unified-data/v1/projects/4/attributeMappings/19629-17", - "relativeId": "projects/4/attributeMappings/19629-17", - "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/address_1", - "relativeInputAttributeId": "datasets/6/attributes/address_1", - "inputDatasetName": "febrl_sample_2k.csv", - "inputAttributeName": "address_1", - "unifiedAttributeId": "unify://unified-data/v1/datasets/79/attributes/surname", - "relativeUnifiedAttributeId": "datasets/79/attributes/surname", - "unifiedDatasetName": "Charlotte_unified_dataset", - "unifiedAttributeName": "surname", - }, - { - "id": "unify://unified-data/v1/projects/4/attributeMappings/19630-16", - "relativeId": "projects/4/attributeMappings/19630-16", - "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/street_number", - "relativeInputAttributeId": "datasets/6/attributes/street_number", - "inputDatasetName": "febrl_sample_2k.csv", - "inputAttributeName": "street_number", - "unifiedAttributeId": "unify://unified-data/v1/datasets/79/attributes/street_number", - "relativeUnifiedAttributeId": "datasets/79/attributes/street_number", - "unifiedDatasetName": "Charlotte_unified_dataset", - "unifiedAttributeName": "street_number", - }, - { - "id": "unify://unified-data/v1/projects/4/attributeMappings/19631-17", - "relativeId": "projects/4/attributeMappings/19631-17", - "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/address_1", - "relativeInputAttributeId": "datasets/6/attributes/address_1", - "inputDatasetName": "febrl_sample_2k.csv", - "inputAttributeName": "address_1", - "unifiedAttributeId": "unify://unified-data/v1/datasets/79/attributes/address_1", - "relativeUnifiedAttributeId": "datasets/79/attributes/address_1", - "unifiedDatasetName": "Charlotte_unified_dataset", - "unifiedAttributeName": "address_1", - }, - { - "id": "unify://unified-data/v1/projects/4/attributeMappings/19632-9", - "relativeId": "projects/4/attributeMappings/19632-9", - "inputAttributeId": "unify://unified-data/v1/datasets/6/attributes/date_of_birth", - "relativeInputAttributeId": "datasets/6/attributes/date_of_birth", - "inputDatasetName": "febrl_sample_2k.csv", - "inputAttributeName": "date_of_birth", - "unifiedAttributeId": "unify://unified-data/v1/datasets/79/attributes/Birthday", - "relativeUnifiedAttributeId": "datasets/79/attributes/Birthday", - "unifiedDatasetName": "Charlotte_unified_dataset", - "unifiedAttributeName": "Birthday", - }, - ] + "inputAttributeName": "date_of_birth", + "unifiedAttributeId": "unify://unified-data/v1/datasets/79/attributes/Birthday", + "relativeUnifiedAttributeId": "datasets/79/attributes/Birthday", + "unifiedDatasetName": "Charlotte_unified_dataset", + "unifiedAttributeName": "Birthday", + }, +] From f4298f87c7d1362d440b9c69ba4e6ac0aa1ac623 Mon Sep 17 00:00:00 2001 From: Nik Bates-Haus Date: Tue, 20 Aug 2019 09:17:34 -0400 Subject: [PATCH 170/632] Incorporate review feedback --- CHANGELOG.md | 2 +- tamr_unify_client/project/attribute_mapping/resource.py | 7 +++++-- tests/unit/test_attribute_mapping.py | 4 ++-- tests/unit/test_attribute_mapping_collection.py | 5 ++--- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e4179d9..7075ff27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## 0.9.0-dev **BREAKING CHANGES** - - `AttributeMapping.__init__()` now takes arguments `client`, `data`, and `alias` (used to take only `data`). + - `AttributeMapping.__init__()` now takes arguments `client` and `data` (used to take only `data`). **NEW FEATURES** - [#218](https://github.com/Datatamer/unify-client-python/issues/218) Delete a `BaseResource` diff --git a/tamr_unify_client/project/attribute_mapping/resource.py b/tamr_unify_client/project/attribute_mapping/resource.py index af2e86c9..969b8fee 100644 --- a/tamr_unify_client/project/attribute_mapping/resource.py +++ b/tamr_unify_client/project/attribute_mapping/resource.py @@ -8,10 +8,13 @@ class AttributeMapping: (ex: /projects/1/attributeMappings/1), but these types of URLs do not exist for attribute mappings """ - def __init__(self, client, data, alias=None): + def __init__(self, client, data): self._data = data self.client = client - self.api_path = alias or self.relative_id + # AttributeMapping cannot be aliased, and Project cannot be aliased, + # so AttributeMapping only ever has one address, which is both + # its relative_id and its api_path. + self.api_path = self.relative_id @property def id(self): diff --git a/tests/unit/test_attribute_mapping.py b/tests/unit/test_attribute_mapping.py index e020aa61..85a264ed 100644 --- a/tests/unit/test_attribute_mapping.py +++ b/tests/unit/test_attribute_mapping.py @@ -1,13 +1,13 @@ import pytest import responses +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth from tamr_unify_client.project.attribute_mapping.resource import AttributeMapping @pytest.fixture def client(): - from tamr_unify_client import Client - from tamr_unify_client.auth import UsernamePasswordAuth return Client(UsernamePasswordAuth("username", "password")) diff --git a/tests/unit/test_attribute_mapping_collection.py b/tests/unit/test_attribute_mapping_collection.py index 9bca87d6..44312da2 100644 --- a/tests/unit/test_attribute_mapping_collection.py +++ b/tests/unit/test_attribute_mapping_collection.py @@ -4,6 +4,8 @@ import pytest import responses +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth from tamr_unify_client.project.attribute_mapping.collection import ( AttributeMappingCollection, ) @@ -12,9 +14,6 @@ @pytest.fixture def client(): - from tamr_unify_client import Client - from tamr_unify_client.auth import UsernamePasswordAuth - return Client(UsernamePasswordAuth("username", "password")) From bb4c92e5a3bb9d167e8197f8db274741a2ce6356 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Tue, 20 Aug 2019 11:42:21 -0400 Subject: [PATCH 171/632] fix docs rendering errors --- docs/contributor-guide.rst | 1 + tamr_unify_client/base_collection.py | 8 +++---- .../project/attribute_mapping/collection.py | 22 +++++++++++++------ .../project/attribute_mapping/resource.py | 5 +++-- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/docs/contributor-guide.rst b/docs/contributor-guide.rst index c66573e4..85dcfbdd 100644 --- a/docs/contributor-guide.rst +++ b/docs/contributor-guide.rst @@ -143,6 +143,7 @@ Overview of Resource and Collection interaction (from_json and from_data confusi .. image:: resource:collectionRoute.png .. image:: resource:collectionRequest.png + **Step 1 (red)**: `yourCollection`’s `by_relative_id` returns `super.by_relative_id`, which comes from `baseCollection` **Step 1a (black)**: within `by_relative_id`, variable `resource_json` is defined as `self.client.get.[etc]`. `Client`’s `.get` returns `self.request` diff --git a/tamr_unify_client/base_collection.py b/tamr_unify_client/base_collection.py index 780359be..fd45e02f 100644 --- a/tamr_unify_client/base_collection.py +++ b/tamr_unify_client/base_collection.py @@ -103,12 +103,12 @@ def by_external_id(self, resource_class, external_id): return items[0] def delete_by_resource_id(self, resource_id): - """Deletes a resource from this collection by resource_id. + """Deletes a resource from this collection by resource ID. - :param resource_id: the resource_id of the resource that will be deleted. - :type: str + :param resource_id: The resource ID of the resource that will be deleted. + :type resource_id: str :return: HTTP response from the server. - :rtype: :class: `requests.Response` + :rtype: :class:`requests.Response` """ path = f"{self.api_path}/{resource_id}" response = self.client.delete(path).successful() diff --git a/tamr_unify_client/project/attribute_mapping/collection.py b/tamr_unify_client/project/attribute_mapping/collection.py index 31f8f7d3..1aea4c1c 100644 --- a/tamr_unify_client/project/attribute_mapping/collection.py +++ b/tamr_unify_client/project/attribute_mapping/collection.py @@ -3,19 +3,23 @@ class AttributeMappingCollection: """Collection of :class:`~tamr_unify_client.project.attribute_mapping.resource.AttributeMapping` - :param map_url: API path used to access this collection. - :type api_path: str + :param client: Client for API call delegation. :type client: :class:`~tamr_unify_client.Client` + :param api_path: API path used to access this collection. + :type api_path: str """ def __init__(self, client, api_path): - self.api_path = api_path self.client = client + self.api_path = api_path def stream(self): - """Stream items in this collection. + """Stream attribute mappings in this collection. Implicitly called when iterating + over this collection. + :returns: Stream of attribute mappings. + :rtype: Python generator yielding :class:`~tamr_unify_client.project.attribute_mapping.resource.AttributeMapping` """ all_maps = self.client.get(self.api_path).successful().json() for mapping in all_maps: @@ -23,6 +27,7 @@ def stream(self): def by_resource_id(self, resource_id): """Retrieve an item in this collection by resource ID. + :param resource_id: The resource ID. :type resource_id: str :returns: The specified attribute mapping. @@ -37,6 +42,7 @@ def by_resource_id(self, resource_id): def by_relative_id(self, relative_id): """Retrieve an item in this collection by relative ID. + :param relative_id: The relative ID. :type relative_id: str :returns: The specified attribute mapping. @@ -47,8 +53,9 @@ def by_relative_id(self, relative_id): def create(self, creation_spec): """Create an Attribute mapping in this collection + :param creation_spec: Attribute mapping creation specification should be formatted as specified in the - `Public Docs for adding an AttributeMapping `_. + `Public Docs for adding an AttributeMapping `_. :type creation_spec: dict[str, str] :returns: The created Attribute mapping :rtype: :class:`~tamr_unify_client.project.attribute_mapping.resource.AttributeMapping` @@ -57,10 +64,11 @@ def create(self, creation_spec): return AttributeMapping(self.client, data) def delete_by_resource_id(self, resource_id): - """delete an attribute mapping using its Resource ID. + """Delete an attribute mapping using its Resource ID. + :param resource_id: the resource ID of the mapping to be deleted. :type resource_id: str - :returns: HTTP status code + :returns: HTTP response from the server :rtype: :class:`requests.Response` """ path = self.api_path + "/" + resource_id diff --git a/tamr_unify_client/project/attribute_mapping/resource.py b/tamr_unify_client/project/attribute_mapping/resource.py index 969b8fee..d7c5b12d 100644 --- a/tamr_unify_client/project/attribute_mapping/resource.py +++ b/tamr_unify_client/project/attribute_mapping/resource.py @@ -81,9 +81,10 @@ def spec(self): return AttributeMappingSpec.of(self) def delete(self): - """delete this attribute mapping. + """Delete this attribute mapping. + :return: HTTP response from the server - :rtype: :class `requests.Response` + :rtype: :class:`requests.Response` """ response = self.client.delete(self.api_path).successful() return response From cc6608d496abe2091ded7bbe33de70a3fbdff765 Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 23 Aug 2019 09:48:00 -0400 Subject: [PATCH 172/632] update links in pyproject.toml --- pyproject.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4d9fd37a..aa850e32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [tool.poetry] name = "tamr-unify-client" version = "0.9.0-dev" -description = "Python Client for the Tamr Unify API" +description = "Python Client for the Tamr API" license = "Apache-2.0" authors = ["Pedro Cattori "] readme = "README.md" -homepage = "https://tamr-unify-python-client.readthedocs.io/en/stable/" -repository = "https://github.com/Datatamer/unify-client-python" -keywords = ["tamr", "unify"] +homepage = "https://tamr-client.readthedocs.io/en/stable/" +repository = "https://github.com/Datatamer/tamr-client" +keywords = ["tamr"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", From e2fc335898b837e0aac59b2858fbc67549e05cfa Mon Sep 17 00:00:00 2001 From: juliamcclellan Date: Fri, 23 Aug 2019 10:30:52 -0400 Subject: [PATCH 173/632] version 0.10.0-dev --- CHANGELOG.md | 4 +++- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7075ff27..888231b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ -## 0.9.0-dev +## 0.10.0-dev + +## 0.9.0 **BREAKING CHANGES** - `AttributeMapping.__init__()` now takes arguments `client` and `data` (used to take only `data`). diff --git a/pyproject.toml b/pyproject.toml index aa850e32..fa7dc7f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "tamr-unify-client" -version = "0.9.0-dev" +version = "0.10.0-dev" description = "Python Client for the Tamr API" license = "Apache-2.0" authors = ["Pedro Cattori "] From 7276427177902ffd1245c6854bd4532d31ed6e95 Mon Sep 17 00:00:00 2001 From: Dominick Olivito Date: Thu, 29 Aug 2019 14:03:33 -0400 Subject: [PATCH 174/632] Update yaml example for secure credentials The YAML example didn't work for me out of the box, but this version does. --- docs/user-guide/secure-credentials.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/user-guide/secure-credentials.rst b/docs/user-guide/secure-credentials.rst index d676343e..937b991f 100644 --- a/docs/user-guide/secure-credentials.rst +++ b/docs/user-guide/secure-credentials.rst @@ -48,9 +48,10 @@ Then ``pip install pyyaml`` read the credentials in your Python code:: from tamr_unify_client.auth import UsernamePasswordAuth import yaml - creds = yaml.load("path/to/credentials.yaml") # replace with your credentials.yaml path + with open("path/to/credentials.yaml") as f: # replace with your credentials.yaml path + creds = yaml.safe_load(f) - auth = UsernamePasswordAuth(creds.username, creds.password) + auth = UsernamePasswordAuth(creds['username'], creds['password']) As in this example, we recommend you use YAML as your format since YAML has support for comments and is more human-readable than JSON. From 14bfbbadeebf82fc168dbd9fa6184aef0235a816 Mon Sep 17 00:00:00 2001 From: Nicole Schmidt Date: Thu, 29 Aug 2019 15:14:48 -0400 Subject: [PATCH 175/632] add `with_external_id` to example project spec --- docs/user-guide/spec.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/user-guide/spec.rst b/docs/user-guide/spec.rst index 3f522044..b33ac217 100644 --- a/docs/user-guide/spec.rst +++ b/docs/user-guide/spec.rst @@ -35,6 +35,7 @@ For instance, to create a project:: .with_type("DEDUP") .with_description("Mastering Project") .with_unified_dataset_name("Project_unified_dataset") + .with_external_id("tamrProject1") ) project = tamr.projects.create(spec.to_dict()) From 8dbe834d4e98e58031288f669041a5055855842c Mon Sep 17 00:00:00 2001 From: Nik Bates-Haus Date: Wed, 28 Aug 2019 21:50:41 -0400 Subject: [PATCH 176/632] Handle empty HTTP 204 responses for already-completed operations. --- tamr_unify_client/dataset/profile.py | 4 +- tamr_unify_client/dataset/resource.py | 10 +- .../mastering/estimated_pair_counts.py | 4 +- tamr_unify_client/operation.py | 42 ++++++++ tests/unit/test_operation.py | 95 +++++++++++++++++++ 5 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 tests/unit/test_operation.py diff --git a/tamr_unify_client/dataset/profile.py b/tamr_unify_client/dataset/profile.py index e53c74b1..35630d4d 100644 --- a/tamr_unify_client/dataset/profile.py +++ b/tamr_unify_client/dataset/profile.py @@ -77,8 +77,8 @@ def refresh(self, **options): :returns: The refresh operation. :rtype: :class:`~tamr_unify_client.operation.Operation` """ - op_json = self.client.post(self.api_path + ":refresh").successful().json() - op = Operation.from_json(self.client, op_json) + response = self.client.post(self.api_path + ":refresh").successful() + op = Operation.from_response(self.client, response) return op.apply_options(**options) def __repr__(self) -> str: diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index 25daa6a3..3f3eb36c 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -144,8 +144,8 @@ def refresh(self, **options): :returns: The refresh operation. :rtype: :class:`~tamr_unify_client.operation.Operation` """ - op_json = self.client.post(self.api_path + ":refresh").successful().json() - op = Operation.from_json(self.client, op_json) + response = self.client.post(self.api_path + ":refresh").successful() + op = Operation.from_response(self.client, response) return op.apply_options(**options) def profile(self): @@ -174,10 +174,8 @@ def create_profile(self, **options): :return: The operation to create the profile. :rtype: :class:`~tamr_unify_client.operation.Operation` """ - op_json = ( - self.client.post(self.api_path + "/profile:refresh").successful().json() - ) - op = Operation.from_json(self.client, op_json) + response = self.client.post(self.api_path + "/profile:refresh").successful() + op = Operation.from_response(self.client, response) return op.apply_options(**options) def records(self): diff --git a/tamr_unify_client/mastering/estimated_pair_counts.py b/tamr_unify_client/mastering/estimated_pair_counts.py index 2eab3ca5..43c84293 100644 --- a/tamr_unify_client/mastering/estimated_pair_counts.py +++ b/tamr_unify_client/mastering/estimated_pair_counts.py @@ -63,8 +63,8 @@ def refresh(self, **options): :returns: The refresh operation. :rtype: :class:`~tamr_unify_client.operation.Operation` """ - op_json = self.client.post(self.api_path + ":refresh").successful().json() - op = Operation.from_json(self.client, op_json) + response = self.client.post(self.api_path + ":refresh").successful() + op = Operation.from_response(self.client, response) return op.apply_options(**options) def __repr__(self) -> str: diff --git a/tamr_unify_client/operation.py b/tamr_unify_client/operation.py index 7a984b07..c38e2bc8 100644 --- a/tamr_unify_client/operation.py +++ b/tamr_unify_client/operation.py @@ -18,6 +18,48 @@ class Operation(BaseResource): def from_json(cls, client, resource_json, api_path=None): return super().from_data(client, resource_json, api_path) + @classmethod + def from_response(cls, client, response): + """ + Handle idiosyncrasies in constructing Operations from Tamr responses. + When a Tamr API call would start an operation, but all results that would be + produced by that operation are already up-to-date, Tamr returns `HTTP 204 No Content` + + To make it easy for client code to handle these API responses without checking + the response code, this method will either construct an Operation, or a + dummy `NoOp` operation representing the 204 Success response. + + :param client: Delegate underlying API calls to this client. + :type client: :class:`~tamr_unify_client.Client` + :param response: HTTP Response from the request that started the operation. + :type response: :class:`requests.Response` + :return: Operation + :rtype: :class:`~tamr_unify_client.operation.Operation` + """ + if response.status_code == 204: + # Operation was successful, but the response contains no content. + # Create a dummy operation to represent this. + _never = "0000-00-00T00:00:00.000Z" + _description = """Tamr returned HTTP 204 for this operation, indicating that all + results that would be produced by the operation are already up-to-date.""" + resource_json = { + "id": "-1", + "type": "NOOP", + "description": _description, + "status": { + "state": "SUCCEEDED", + "startTime": _never, + "endTime": _never, + "message": "", + }, + "created": {"username": "", "time": _never, "version": "-1"}, + "lastModified": {"username": "", "time": _never, "version": "-1"}, + "relativeId": "operations/-1", + } + else: + resource_json = response.json() + return Operation.from_json(client, resource_json) + def apply_options(self, asynchronous=False, **options): """Applies operation options to this operation. diff --git a/tests/unit/test_operation.py b/tests/unit/test_operation.py new file mode 100644 index 00000000..96398dd3 --- /dev/null +++ b/tests/unit/test_operation.py @@ -0,0 +1,95 @@ +from urllib.parse import urljoin + +import pytest +from requests import HTTPError +import responses + + +from tamr_unify_client.operation import Operation + + +@pytest.fixture +def client(): + from tamr_unify_client import Client + from tamr_unify_client.auth import UsernamePasswordAuth + + auth = UsernamePasswordAuth("username", "password") + tamr = Client(auth) + return tamr + + +def full_url(client, endpoint): + return urljoin(client.origin + client.base_path, endpoint) + + +op_1_json = { + "id": "1", + "type": "SPARK", + "description": "Profiling [dataset] attributes.", + "status": { + "state": "SUCCEEDED", + "startTime": "2019-08-28T18:51:06.856Z", + "endTime": "2019-08-28T18:53:08.204Z", + "message": "", + }, + "created": { + "username": "admin", + "time": "2019-08-28T18:50:35.582Z", + "version": "17", + }, + "lastModified": { + "username": "system", + "time": "2019-08-28T18:53:08.950Z", + "version": "40", + }, + "relativeId": "operations/1", +} + + +def test_operation_from_json(client): + alias = "operations/123" + op1 = Operation.from_json(client, op_1_json, alias) + assert op1.api_path == alias + assert op1.relative_id == op_1_json["relativeId"] + assert op1.resource_id == "1" + assert op1.type == op_1_json["type"] + assert op1.description == op_1_json["description"] + assert op1.status == op_1_json["status"] + assert op1.state == "SUCCEEDED" + assert op1.succeeded + + +@responses.activate +def test_operation_from_response(client): + responses.add(responses.GET, full_url(client, "operations/1"), json=op_1_json) + + op1 = Operation.from_response(client, client.get("operations/1").successful()) + + assert op1.resource_id == "1" + assert op1.succeeded + + +@responses.activate +def test_operation_from_response_noop(client): + responses.add(responses.GET, full_url(client, "operations/2"), status=204) + responses.add(responses.GET, full_url(client, "operations/-1"), status=404) + + op2 = Operation.from_response(client, client.get("operations/2").successful()) + + assert op2.api_path is not None + assert op2.relative_id is not None + assert op2.resource_id is not None + assert op2.type == "NOOP" + assert op2.description is not None + assert op2.status is not None + assert op2.state == "SUCCEEDED" + assert op2.succeeded + + op2a = op2.apply_options(asynchronous=True) + assert op2a.succeeded + + op2w = op2a.wait() + assert op2w.succeeded + + with pytest.raises(HTTPError): + op2w.poll() From c1d5f8a5c0f25dd3146b423f6c25936cc1999b2f Mon Sep 17 00:00:00 2001 From: Nik Bates-Haus Date: Thu, 29 Aug 2019 17:00:15 -0400 Subject: [PATCH 177/632] Changelog entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 888231b2..1190181f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ ## 0.10.0-dev + **BUG FIXES** + - [#293](https://github.com/Datatamer/tamr-client/issues/293) Better handling for HTTP 204 on already up-to-date operations ## 0.9.0 **BREAKING CHANGES** From 2e9eed9db5c053fff154dc5c09755e16be0db40d Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 2 Oct 2019 21:26:46 -0400 Subject: [PATCH 178/632] fix(docs): Remove occurence of "Unify" See https://github.com/Datatamer/tamr-client/issues/251 --- .github/ISSUE_TEMPLATE/BUG_REPORT.md | 2 +- .github/ISSUE_TEMPLATE/QUESTION.md | 2 +- .github/PULL_REQUEST_TEMPLATE.md | 4 ++-- RELEASE.md | 10 +++++----- docs/conf.py | 22 ++++++++-------------- docs/index.rst | 4 ++-- docs/user-guide/spec.rst | 2 +- 7 files changed, 20 insertions(+), 26 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.md b/.github/ISSUE_TEMPLATE/BUG_REPORT.md index 139d795c..18fe189f 100644 --- a/.github/ISSUE_TEMPLATE/BUG_REPORT.md +++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.md @@ -44,6 +44,6 @@ Search open/closed issues before submitting since someone might have asked the s | Software | Version(s) | | ----------------- | ---------- | | tamr-unify-client | -| Tamr Unify server | +| Tamr server | | Python | | Operating System | diff --git a/.github/ISSUE_TEMPLATE/QUESTION.md b/.github/ISSUE_TEMPLATE/QUESTION.md index 026166d4..c222c533 100644 --- a/.github/ISSUE_TEMPLATE/QUESTION.md +++ b/.github/ISSUE_TEMPLATE/QUESTION.md @@ -30,6 +30,6 @@ Search open/closed issues before submitting since someone might have asked the s | Software | Version(s) | | ----------------- | ---------- | | tamr-unify-client | -| Tamr Unify server | +| Tamr server | | Python | | Operating System | diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 459ee8f9..647c08b8 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -19,7 +19,7 @@ Please look for any issues that this PR resolves and tag them in the PR. - [ ] Added/updated testing for this change - [ ] Included links to related issues/PRs -- [ ] Update relevant [docs](https://github.com/Datatamer/unify-client-python/tree/master/docs) + docstrings -- [ ] Update the [CHANGELOG](https://github.com/Datatamer/unify-client-python/blob/master/CHANGELOG.md) under the current `-dev` version: +- [ ] Update relevant [docs](https://github.com/Datatamer/tamr-client/tree/master/docs) + docstrings +- [ ] Update the [CHANGELOG](https://github.com/Datatamer/tamr-client/blob/master/CHANGELOG.md) under the current `-dev` version: - Add changelog entries under any that apply: **BREAKING CHANGES**, **NEW FEATURES**, **BUG FIXES**. - Changelog entry format: `[#]() ` diff --git a/RELEASE.md b/RELEASE.md index 119a3079..a3393209 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -20,9 +20,9 @@ Ensure CI tests pass for your PR and merge your changes into `master`. # 2. Cut a release branch -On the [Datatamer/unify-client-python](https://github.com/Datatamer/unify-client-python) Github repo, click on [Commits](https://github.com/Datatamer/unify-client-python/commits/master). Navigate to the commit just before the version bump commit from Step 1. Click the `<>` icon to browse the repo at that commit. +On the [Datatamer/tamr-client](https://github.com/Datatamer/tamr-client) Github repo, click on [Commits](https://github.com/Datatamer/tamr-client/commits/master). Navigate to the commit just before the version bump commit from Step 1. Click the `<>` icon to browse the repo at that commit. -Then, create a branch on Github within the [Datatamer/unify-client-python](https://github.com/Datatamer/unify-client-python) repo titled `release-` e.g. `release-0.3.0`. +Then, create a branch on Github within the [Datatamer/tamr-client](https://github.com/Datatamer/tamr-client) repo titled `release-` e.g. `release-0.3.0`. Create a branch locally with the following commands: 1. `git fetch Datatamer` (this will pull down the release branch you created on Github) @@ -41,7 +41,7 @@ Ensure CI tests pass for your PR and merge your changes into the release branch # 4. Create a Github release -On the [Datatamer/unify-client-python](https://github.com/Datatamer/unify-client-python) Github repo, click on [Releases](https://github.com/Datatamer/unify-client-python/releases). Click "Draft a new release". +On the [Datatamer/tamr-client](https://github.com/Datatamer/tamr-client) Github repo, click on [Releases](https://github.com/Datatamer/tamr-client/releases). Click "Draft a new release". Title the release with the release version. Do not include anything else in the release title e.g. - Correct: `0.3.0` @@ -52,13 +52,13 @@ Title the release with the release version. Do not include anything else in the Copy/paste the `CHANGELOG.md` entries for this release into the description for the release (only the entries, not the header since the version number is already encoded as the title for this release). -Create the release. This should also implicitly create a tag for the release under [Tags](https://github.com/Datatamer/unify-client-python/tags). +Create the release. This should also implicitly create a tag for the release under [Tags](https://github.com/Datatamer/tamr-client/tags). # 5. Check on published artifacts We use Travis CI as our Continuous Integration (CI) solution. -CI is wired to ["deploy"](https://github.com/Datatamer/unify-client-python/blob/master/.travis.yml#L14) (a.k.a. publish) releases to PyPI for any tags that look like a semantic version number e.g. `0.3.0`. So CI should handle publishing for you. +CI is wired to ["deploy"](https://github.com/Datatamer/tamr-client/blob/master/.travis.yml#L14) (a.k.a. publish) releases to PyPI for any tags that look like a semantic version number e.g. `0.3.0`. So CI should handle publishing for you. Check that CI tests passed. Check that CI successfully published the release version to [PyPI](https://pypi.org/project/tamr-unify-client/#history). diff --git a/docs/conf.py b/docs/conf.py index ef24e7fc..0fe96fae 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,7 +22,7 @@ # -- Project information ----------------------------------------------------- -project = "Tamr Unify Python Client" +project = "Tamr - Python Client" copyright = "2018, Tamr" author = "Tamr" @@ -118,7 +118,7 @@ # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. -htmlhelp_basename = "TamrUnifyPythonClientdoc" +htmlhelp_basename = "TamrPythonClientdoc" # -- Options for LaTeX output ------------------------------------------------ @@ -144,8 +144,8 @@ latex_documents = [ ( master_doc, - "TamrUnifyPythonClient.tex", - "Tamr Unify Python Client Documentation", + "TamrPythonClient.tex", + "Tamr - Python Client Documentation", "Tamr", "manual", ) @@ -157,13 +157,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ( - master_doc, - "tamrunifypythonclient", - "Tamr Unify Python Client Documentation", - [author], - 1, - ) + (master_doc, "tamrpythonclient", "Tamr - Python Client Documentation", [author], 1) ] @@ -175,10 +169,10 @@ texinfo_documents = [ ( master_doc, - "TamrUnifyPythonClient", - "Tamr Unify Python Client Documentation", + "TamrPythonClient", + "Tamr - Python Client Documentation", author, - "TamrUnifyPythonClient", + "TamrPythonClient", "One line description of project.", "Miscellaneous", ) diff --git a/docs/index.rst b/docs/index.rst index ace3dedb..7725cd4e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,7 +1,7 @@ -Tamr Unify - Python Client +Tamr - Python Client ========================== -Version: |release| | `View on Github `_ +Version: |release| | `View on Github `_ Example ------- diff --git a/docs/user-guide/spec.rst b/docs/user-guide/spec.rst index b33ac217..f232604a 100644 --- a/docs/user-guide/spec.rst +++ b/docs/user-guide/spec.rst @@ -86,7 +86,7 @@ Modifying a resource Certain resources can also be modified using specs. After getting a spec corresponding to a resource and modifying some properties, -the updated resource can be committed to Unify with the ``put`` function:: +the updated resource can be committed to Tamr with the ``put`` function:: updated_dataset = ( dataset.spec() From 1436543c7f842f7e3f00f27edc892266e0f12348 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 2 Oct 2019 21:28:39 -0400 Subject: [PATCH 179/632] fix(docs): update broken reference to `requests` documentation Documentation is no longer at old URL. --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 0fe96fae..daf5199f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -51,7 +51,7 @@ autodoc_member_order = "bysource" intersphinx_mapping = { "https://docs.python.org/": None, - "requests": ("http://docs.python-requests.org/en/master/", None), + "requests": ("https://requests.kennethreitz.org/en/master/", None), "pandas": ("https://pandas.pydata.org/pandas-docs/stable", None), } From bbf541b28a68b09645f65277a67fef872c34a8c1 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 2 Oct 2019 21:34:34 -0400 Subject: [PATCH 180/632] feat(docs): sphinx.ext.napoleon for Google-style docstrings Replaces sphinx.ext.autodoc --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index daf5199f..dd3dca5e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -46,7 +46,7 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ["sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx.ext.viewcode"] +extensions = ["sphinx.ext.napoleon", "sphinx.ext.intersphinx", "sphinx.ext.viewcode"] autodoc_default_flags = ["inherited-members", "members"] autodoc_member_order = "bysource" intersphinx_mapping = { From a4aea6512826f398d950e0eb9e3f1edb6823358a Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 2 Oct 2019 21:47:12 -0400 Subject: [PATCH 181/632] fix(docs): White logo image for contrast with sphinx_rtd_theme Normal colored logo has shades of blue that conflict with sphinx_rtd_theme color scheme and makes it hard to see the logo. --- docs/_static/tamr.png | Bin 53808 -> 114817 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/_static/tamr.png b/docs/_static/tamr.png index 6f9720738295f102d7f31a2a7113270efd66841e..390233ef5697891e38484765a432322bd1513662 100644 GIT binary patch literal 114817 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

%<|Q`FT{>QdQ5E+cn<*O@3lvp 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∾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­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%0D=}XuSH_ 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

p|MPO{8-&^ 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�&t{6o!F`noeP)!aWmzbXt5Rv(n$2?qb+YAqlTf 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

-zD5D_;-GDG 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%0AI#9kC<7P>VF)q411vDc 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 diff --git a/docs/contributor-guide/style-guide.md b/docs/contributor-guide/style-guide.md index 4d4b7185..b2df10eb 100644 --- a/docs/contributor-guide/style-guide.md +++ b/docs/contributor-guide/style-guide.md @@ -4,7 +4,7 @@ Code should generally conform to the [PEP8 style guidelines](https://www.python.org/dev/peps/pep-0008/). * [Flake8](https://flake8.pycqa.org/en/latest/) is a linter to help check that code is aligned with these formatting requirements * [Black](https://black.readthedocs.io/en/stable/) is a formatter that can be used to automatically reformat code to resolve many (but not all) formatting issues - * For details on using these tools [see here](run-and-build) + * For details on using these tools, see [the dev tasks guide](dev-tasks) ### Structure * Classes with methods should be avoided in favor of simple [dataclasses](https://docs.python.org/3/library/dataclasses.html) and functions diff --git a/docs/contributor-guide/text-editor.md b/docs/contributor-guide/text-editor.md new file mode 100644 index 00000000..3ad0fc1e --- /dev/null +++ b/docs/contributor-guide/text-editor.md @@ -0,0 +1,8 @@ +# Configure your Text Editor + +### Atom +- [python-black](https://atom.io/packages/python-black) +- [linter-flake8](https://atom.io/packages/linter-flake8) + +### VS Code +- [Python](https://marketplace.visualstudio.com/items?itemName=ms-python.python) \ No newline at end of file diff --git a/docs/contributor-guide/toolchain.md b/docs/contributor-guide/toolchain.md deleted file mode 100644 index 88fea3f2..00000000 --- a/docs/contributor-guide/toolchain.md +++ /dev/null @@ -1,28 +0,0 @@ -# Toolchain - -This project uses `poetry` as its package manager. For details on `poetry`, -see the [official documentation](https://poetry.eustace.io/). - - 1. Install [pyenv](https://github.com/pyenv/pyenv#installation>): - - ```sh - curl https://pyenv.run | bash - ``` - - 2. Use `pyenv` to install all Python versions in [.python-version](https://github.com/Datatamer/tamr-client/blob/master/.python-version): - - [Automated tests](run-and-build) will use these Python versions. - - ```sh - cd tamr-client/ # or wherever you cloned Datatamer/tamr-client - - # run `pyenv install` for each line in `.python-version` - cat .python-version | xargs -L 1 pyenv install - ``` - - 4. Install `poetry` with `python` 3.6+ as [described here](https://poetry.eustace.io/docs/#installation): - - ```sh - python --version # check that version is 3.6.9 - curl -sSL https://raw.githubusercontent.com/sdispater/poetry/master/get-poetry.py | python - ``` From 04c96f9ec721e4dd1f7246b44a5c2737231227b0 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 24 Jun 2020 19:01:38 -0400 Subject: [PATCH 436/632] Ask contributors to run CI checks locally Also, minor grammar fixes --- docs/contributor-guide.md | 2 +- docs/contributor-guide/pull-request.md | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/contributor-guide.md b/docs/contributor-guide.md index 99ad370a..647b27e7 100644 --- a/docs/contributor-guide.md +++ b/docs/contributor-guide.md @@ -10,7 +10,7 @@ If the bug/feature has been submitted already, leave a like 👍 on the descript Submit bug reports as [Github issues](https://github.com/Datatamer/tamr-client/issues/new/choose). ### Feature requests -Submit feature requests as [Github issues](https://github.com/Datatamer/tamr-client/issues/new/choose) +Submit feature requests as [Github issues](https://github.com/Datatamer/tamr-client/issues/new/choose). ## Contributing code diff --git a/docs/contributor-guide/pull-request.md b/docs/contributor-guide/pull-request.md index 3cc5cd89..983e23a4 100644 --- a/docs/contributor-guide/pull-request.md +++ b/docs/contributor-guide/pull-request.md @@ -37,4 +37,11 @@ Contributions / PRs should follow the Split and squash commits as necessary to create a clean `git` history. Once you ask for review, only add new commits (do not change existing commits) for reviewer convenience. You may change commits in your PR only if reviewers are ok with it. -Also, write [good commit messages](https://chris.beams.io/posts/git-commit/)! \ No newline at end of file +Also, write [good commit messages](https://chris.beams.io/posts/git-commit/)! + +### CI checks + +Continuous integration (CI) checks are run automatically for all pull requests. +CI runs the same [dev tasks](dev-tasks) that you can run locally. + +You should run dev tasks locally _before_ submitting your PR to cut down on subsequent commits to fix the CI checks. From 2886ff959b4e2d698b24fedec1141cb528f496c2 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 25 Jun 2020 08:00:39 -0400 Subject: [PATCH 437/632] Guide contributors to the latest version of the docs --- docs/contributor-guide.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/contributor-guide.md b/docs/contributor-guide.md index 647b27e7..7c4e63ca 100644 --- a/docs/contributor-guide.md +++ b/docs/contributor-guide.md @@ -1,5 +1,7 @@ # Contributor guide +Make sure you are viewing the [latest version of Contributor Guide](https://tamr-client.readthedocs.io/en/latest/contributor-guide.html). + ## Contributing feedback Check through existing issues (open and closed) to confirm that the bug or feature hasn’t been reported before. From bd1d4f751a8f9f936d4ecc631eecd49cc5bc0155 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 25 Jun 2020 10:35:14 -0400 Subject: [PATCH 438/632] Explain how user-facing docs should be written --- docs/contributor-guide.md | 7 +++-- docs/contributor-guide/how-to-write-docs.md | 32 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 docs/contributor-guide/how-to-write-docs.md diff --git a/docs/contributor-guide.md b/docs/contributor-guide.md index 7c4e63ca..3f737ab0 100644 --- a/docs/contributor-guide.md +++ b/docs/contributor-guide.md @@ -2,7 +2,7 @@ Make sure you are viewing the [latest version of Contributor Guide](https://tamr-client.readthedocs.io/en/latest/contributor-guide.html). -## Contributing feedback +## Feedback Check through existing issues (open and closed) to confirm that the bug or feature hasn’t been reported before. @@ -14,8 +14,11 @@ Submit bug reports as [Github issues](https://github.com/Datatamer/tamr-client/i ### Feature requests Submit feature requests as [Github issues](https://github.com/Datatamer/tamr-client/issues/new/choose). +## Documentation -## Contributing code +* [How to write user-facing documentation](contributor-guide/how-to-write-docs) + +## Code * [Install the codebase](contributor-guide/install) * [Run dev tasks](contributor-guide/dev-tasks) * [Configure your text editor](contributor-guide/text-editor) diff --git a/docs/contributor-guide/how-to-write-docs.md b/docs/contributor-guide/how-to-write-docs.md new file mode 100644 index 00000000..53c2b272 --- /dev/null +++ b/docs/contributor-guide/how-to-write-docs.md @@ -0,0 +1,32 @@ +# How to write docs + +When writing user-facing documentation, pick one of the four options: +1. Tutorial +2. How-To guide +3. Explanation +4. Reference + +For more, see descriptions below or [Divio's documentation system manual](https://documentation.divio.com/). + +### Tutorial + +Tutorials are learning-oriented and ... +- must walk-through a self-contained domain like "Deduplicating buildings in Cambridge". +- must provide any necessary configuration and data for the tutorial. + +### How-To guide + +How-To guides are problem-oriented and ... +- must answer a specific, domain-agnostic question like "How to stream datasets out of Tamr" + +### Explanation + +Explanations are understanding-oriented and ... +- must explain core concepts of Tamr or the Tamr client +- may include code examples + +### Reference + +Reference is information-oriented. + +Our reference documentation is automatically generated by [autodoc](https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html) based on type annotations and docstrings in the source code. \ No newline at end of file From 1ebd58174fa6fd169fb48304d7097607bf0c9077 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 26 Jun 2020 13:02:32 -0400 Subject: [PATCH 439/632] Reorder Contributor Guide to be after Reference --- docs/index.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/index.md b/docs/index.md index 9a5aca15..ec3261b0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -38,14 +38,14 @@ assert op.succeeded() * [Pandas usage](user-guide/pandas) * [Advanced usage](user-guide/advanced-usage) -## Contributor Guide - - * [Contributor guide](contributor-guide) - ## Reference * [Reference](reference) +## Contributor Guide + + * [Contributor guide](contributor-guide) + ## BETA * [BETA](beta) From ca578de081273602dd75771301a66af434d4a400 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 26 Jun 2020 13:02:57 -0400 Subject: [PATCH 440/632] Rephrase "how to write docs" guide --- docs/contributor-guide.md | 8 ++-- docs/contributor-guide/how-to-write-docs.md | 53 ++++++++++++++++----- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/docs/contributor-guide.md b/docs/contributor-guide.md index 3f737ab0..dd9cc363 100644 --- a/docs/contributor-guide.md +++ b/docs/contributor-guide.md @@ -1,12 +1,14 @@ # Contributor guide -Make sure you are viewing the [latest version of Contributor Guide](https://tamr-client.readthedocs.io/en/latest/contributor-guide.html). +Thank you for learning how to contribute to the Tamr's Python Client! +Your contribution will help you and many others in the Tamr community. +Before you begin, make sure you are viewing the [latest version of Contributor Guide](https://tamr-client.readthedocs.io/en/latest/contributor-guide.html). ## Feedback -Check through existing issues (open and closed) to confirm that the bug or feature hasn’t been reported before. - +Before submitting a new issue, [you can search existing issues](https://github.com/Datatamer/tamr-client/issues?q=is%3Aissue). If the bug/feature has been submitted already, leave a like 👍 on the description of the Github Issue. +Maintainers will consider number of likes when prioritizing issues. ### Bug reports Submit bug reports as [Github issues](https://github.com/Datatamer/tamr-client/issues/new/choose). diff --git a/docs/contributor-guide/how-to-write-docs.md b/docs/contributor-guide/how-to-write-docs.md index 53c2b272..d001149a 100644 --- a/docs/contributor-guide/how-to-write-docs.md +++ b/docs/contributor-guide/how-to-write-docs.md @@ -1,32 +1,63 @@ # How to write docs -When writing user-facing documentation, pick one of the four options: +Before you begin to add content, decide which of the three types of content you want to add: 1. Tutorial 2. How-To guide 3. Explanation -4. Reference -For more, see descriptions below or [Divio's documentation system manual](https://documentation.divio.com/). +``` note:: + There is fourth type of content, known as Reference. + + For the Tamr Client, you don't need to add reference topics manually because reference documentation for the Tamr Client is generated automatically based on the source code. + + For more details, see Reference description below. +``` + +For more information about each type of content, see the following descriptions. +Also see [Divio's documentation system manual](https://documentation.divio.com/). ### Tutorial Tutorials are learning-oriented and ... -- must walk-through a self-contained domain like "Deduplicating buildings in Cambridge". -- must provide any necessary configuration and data for the tutorial. -### How-To guide +- Must include an end-to-end walkthrough for a specific use case, such as "Tutorial: Deduplicating buildings in Cambridge". +- Must have a clearly stated goal and allow the users to achieve it after they complete the steps in the tutorial. +- Must provide the sample data and input configuration that are necessary for the user to complete the tutorial. Include this information upfront, at the start of your tutorial. +- Must be self-contained, but can include links to procedures described elsewhere in this documentation. + +Tutorials are useful if the use case is both simple and in high demand. +Not every use case deserves a tutorial. +Before writing a tutorial, think first of a use case that has a high learning value, and then prepare the assets needed to complete your tutorial, such as a sample dataset and sample configuration. + +Tutorials are in high demand. +If you write a good one, many users will reference it and thank you for your work! -How-To guides are problem-oriented and ... -- must answer a specific, domain-agnostic question like "How to stream datasets out of Tamr" +### How-To + +How-Tos are task-oriented and ... + +- Must include a list of numbered steps, known as a task, or a procedure, to help users complete a specific, domain-agnostic task, such as running a request, copying a file, installing, exporting, or other. For example, you can create a task titled "How to stream datasets out of Tamr". +- Must include a context paragraph, such as "It is often useful to stream datasets from Tamr, to load them into business analytics applications, such as Tableau, for analysis." Context may also include checks needed to be in place before users start the task, and links to related concepts. Context must provide information needed to begin the task, such as, it can list the host and port URL at which the endpoint for the service is served. +- Must include a stem sentence, such as: "To stream a dataset out of Tamr:" The stem sentence is followed by numbered steps. +- Must include a numbered list of steps where each step must begin with an imperative verb, such as: "Run the following curl request.", or "Save the file". For more examples see [Use Imperatives in Procedures](http://www.cs.cmu.edu/afs/cs.cmu.edu/project/cmt-40/kantoo/vol40/doc/kce/styleguide/imperatives.html). ### Explanation Explanations are understanding-oriented and ... -- must explain core concepts of Tamr or the Tamr client -- may include code examples + +- Must explain a single concept of the Tamr Python client. If you'd like to write another concept, create it separately. +- Must [keep sentences short](http://www.cs.cmu.edu/afs/cs.cmu.edu/project/cmt-40/kantoo/vol40/doc/kce/styleguide/shortsentences.html). +- May include examples of code or text examples. ### Reference Reference is information-oriented. -Our reference documentation is automatically generated by [autodoc](https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html) based on type annotations and docstrings in the source code. \ No newline at end of file +It is something that users cannot remember and want to be able to refer to, often. +Reference provides details, such as configuration parameters for a particular method or call. +It never contains tasks, or concepts. +Reference is often automatically-generated from code, to ensure it is up-to-date and accurate at all times. + +``` note:: + Our reference documentation is automatically generated by [autodoc](https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html) based on type annotations and docstrings in the source code. +``` \ No newline at end of file From d6be8e38ad5fa683dfaee23594ae607088954ccb Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 26 Jun 2020 13:43:43 -0400 Subject: [PATCH 441/632] Fix typo --- docs/contributor-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contributor-guide.md b/docs/contributor-guide.md index dd9cc363..7056ddff 100644 --- a/docs/contributor-guide.md +++ b/docs/contributor-guide.md @@ -1,6 +1,6 @@ # Contributor guide -Thank you for learning how to contribute to the Tamr's Python Client! +Thank you for learning how to contribute to Tamr's Python Client! Your contribution will help you and many others in the Tamr community. Before you begin, make sure you are viewing the [latest version of Contributor Guide](https://tamr-client.readthedocs.io/en/latest/contributor-guide.html). From 822c45dd7aabf8ed5dce26fd2a57bd8dd1bcc246 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Sat, 27 Jun 2020 13:19:42 -0400 Subject: [PATCH 442/632] Add section for maintainers in contributor docs Also, describe how others can become maintainers --- docs/contributor-guide.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/contributor-guide.md b/docs/contributor-guide.md index 7056ddff..b0537e57 100644 --- a/docs/contributor-guide.md +++ b/docs/contributor-guide.md @@ -26,3 +26,18 @@ Submit feature requests as [Github issues](https://github.com/Datatamer/tamr-cli * [Configure your text editor](contributor-guide/text-editor) * [Read the style guide](contributor-guide/style-guide) * [Submit a pull request](contributor-guide/pull-request) + + +## Maintainers + +Maintainer responsabilities: +- Triage issues +- Review + merge pull requests +- Discuss RFCs +- Publish new releases + +Current maintainers: +- [pcattori](https://github.com/pcattori) + +Want to become a maintainer? +Open a pull request that adds your name to the list of current maintainers! \ No newline at end of file From f6b6112558623cd2f19569b60aefef41c13a4f06 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Sat, 27 Jun 2020 18:28:11 -0400 Subject: [PATCH 443/632] Consolidate methods for building docs into noxfile Guarantee that CI builds docs the same way devs can locally. --- .github/workflows/ci.yml | 11 ++++------- noxfile.py | 4 +++- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 096326a4..12f59e88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,10 +82,7 @@ jobs: uses: actions/setup-python@v1.1.1 with: python-version: 3.6 - # RTD uses pip for managing dependencies, so we mirror that approach - - name: Install dependencies - run: | - python -m pip install . - python -m pip install -r docs/requirements.txt - - name: Build docs - run: TAMR_CLIENT_BETA=1 sphinx-build -b html docs docs/_build -W + - name: Install nox + run: pip install nox==2020.5.24 + - name: Run sphinx-build + run: nox -s docs \ No newline at end of file diff --git a/noxfile.py b/noxfile.py index e9400988..d2f43b21 100644 --- a/noxfile.py +++ b/noxfile.py @@ -46,7 +46,9 @@ def test(session): @nox.session(python="3.6") def docs(session): - session.run("poetry", "install", external=True) + # RTD uses pip for managing dependencies, so we mirror that approach + session.install(".") + session.install("-r", "docs/requirements.txt") session.run( "sphinx-build", "-b", From bfedc3a2f15b880e0e0cd719ed8bd0773a7ba6c2 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 26 Jun 2020 20:09:55 -0400 Subject: [PATCH 444/632] Change doc styling - Change title/header font to Lexend Deca - Include terms and conditions in copyright footer - Remove "Built with Sphinx" callout - Change top-left quadrant color to Tamr's Brilliant Blue - Set favicon to Tamr icon --- docs/_static/css/custom.css | 20 ++++++++++++++++++++ docs/_static/favicon.png | Bin 0 -> 49154 bytes docs/_templates/footer.html | 8 ++++++++ docs/conf.py | 6 +++++- 4 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 docs/_static/css/custom.css create mode 100644 docs/_static/favicon.png create mode 100644 docs/_templates/footer.html diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css new file mode 100644 index 00000000..f7ae6265 --- /dev/null +++ b/docs/_static/css/custom.css @@ -0,0 +1,20 @@ +@import url('https://fonts.googleapis.com/css2?family=Lexend+Deca&display=swap'); + +h1, +h2, +.rst-content .toctree-wrapper p.caption, +h3, +h4, +h5, +h6, +legend { + font-family: 'Lexend Deca', sans-serif; +} + +.wy-side-nav-search { + background-color: #0859C6; +} + +.wy-side-nav-search input[type="text"] { + border-color: unset; +} \ No newline at end of file diff --git a/docs/_static/favicon.png b/docs/_static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..460b38d8c10fff89fc2affdeb09827a5e418e3ae GIT binary patch literal 49154 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⪻X5S6630IGL~Pp0W!J&> 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 F{|jMe8>j#P literal 0 HcmV?d00001 diff --git a/docs/_templates/footer.html b/docs/_templates/footer.html new file mode 100644 index 00000000..527cb84b --- /dev/null +++ b/docs/_templates/footer.html @@ -0,0 +1,8 @@ +{% extends '!footer.html' %} +{% block extrafooter %} +

+{% endblock %} \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 86a50dbe..4b577c64 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,7 +24,6 @@ # -- Project information ----------------------------------------------------- project = "Tamr - Python Client" -copyright = "2020, Tamr" author = "Tamr" @@ -97,6 +96,10 @@ # a list of builtin themes. # html_theme = "sphinx_rtd_theme" +html_favicon = "_static/favicon.png" +html_show_copyright = False # custom copyright in _templates/footer.html +html_show_sphinx = False + # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -109,6 +112,7 @@ # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] +html_css_files = ["css/custom.css"] html_logo = "_static/tamr.png" html_sidebars = {"**": ["localtoc.html", "relations.html", "searchbox.html"]} From 88ed70546825d8529fe31f2832909d8cbcb8b31a Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 26 Jun 2020 20:20:52 -0400 Subject: [PATCH 445/632] Increase h1 font size ...to distinguish from h2 --- docs/_static/css/custom.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css index f7ae6265..621bc108 100644 --- a/docs/_static/css/custom.css +++ b/docs/_static/css/custom.css @@ -11,6 +11,10 @@ legend { font-family: 'Lexend Deca', sans-serif; } +h1 { + font-size: 225%; +} + .wy-side-nav-search { background-color: #0859C6; } From a51de9cbe62a0415a92f71cb45e92a6febca9858 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 29 Jun 2020 10:33:18 -0400 Subject: [PATCH 446/632] Ignore linting errors explicitly for top-level __init__.py --- .flake8 | 3 +++ tamr_client/__init__.py | 4 +--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.flake8 b/.flake8 index 69541160..e2a7054c 100644 --- a/.flake8 +++ b/.flake8 @@ -8,6 +8,9 @@ max-complexity = 18 select = B,C,E,F,I,W,T4,B9 exclude = build,.venv,*.egg-info +per-file-ignores = + tamr_client/__init__.py:E402,F401,I100,I202 + # flake8-import-order plugin import-order-style = google application-import-names = tamr_client, tamr_unify_client, tests diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 43ec7d2b..2110f4e3 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -1,5 +1,3 @@ -# flake8: noqa - # BETA check ############ @@ -59,9 +57,9 @@ from tamr_client.attributes.attribute import ( Attribute, - ReservedAttributeName, AttributeExists, AttributeNotFound, + ReservedAttributeName, ) from tamr_client.attributes import attribute From 3cc41ff3c5d3f2861e346b47e16ccf4fead1a1cf Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 29 Jun 2020 10:36:47 -0400 Subject: [PATCH 447/632] Move JsonDict type into _types package --- .flake8 | 1 + tamr_client/_types/__init__.py | 1 + tamr_client/{types.py => _types/json.py} | 0 tamr_client/attributes/attribute.py | 2 +- tamr_client/attributes/attribute_type.py | 2 +- tamr_client/attributes/subattribute.py | 2 +- tamr_client/dataset/dataframe.py | 2 +- tamr_client/dataset/dataset.py | 2 +- tamr_client/dataset/record.py | 2 +- tamr_client/dataset/unified.py | 2 +- tamr_client/mastering/project.py | 2 +- tamr_client/operation.py | 2 +- tamr_client/project.py | 2 +- tamr_client/response.py | 2 +- 14 files changed, 13 insertions(+), 11 deletions(-) create mode 100644 tamr_client/_types/__init__.py rename tamr_client/{types.py => _types/json.py} (100%) diff --git a/.flake8 b/.flake8 index e2a7054c..5d97a7e9 100644 --- a/.flake8 +++ b/.flake8 @@ -10,6 +10,7 @@ exclude = build,.venv,*.egg-info per-file-ignores = tamr_client/__init__.py:E402,F401,I100,I202 + tamr_client/_types/__init__.py:F401 # flake8-import-order plugin import-order-style = google diff --git a/tamr_client/_types/__init__.py b/tamr_client/_types/__init__.py new file mode 100644 index 00000000..cd97b4cd --- /dev/null +++ b/tamr_client/_types/__init__.py @@ -0,0 +1 @@ +from .json import JsonDict diff --git a/tamr_client/types.py b/tamr_client/_types/json.py similarity index 100% rename from tamr_client/types.py rename to tamr_client/_types/json.py diff --git a/tamr_client/attributes/attribute.py b/tamr_client/attributes/attribute.py index ab0cb924..b39c31d1 100644 --- a/tamr_client/attributes/attribute.py +++ b/tamr_client/attributes/attribute.py @@ -6,11 +6,11 @@ from typing import Optional, Tuple from tamr_client import response +from tamr_client._types import JsonDict from tamr_client.attributes import attribute_type, type_alias from tamr_client.attributes.attribute_type import AttributeType from tamr_client.dataset.dataset import Dataset from tamr_client.session import Session -from tamr_client.types import JsonDict from tamr_client.url import URL diff --git a/tamr_client/attributes/attribute_type.py b/tamr_client/attributes/attribute_type.py index d1a27234..bd840102 100644 --- a/tamr_client/attributes/attribute_type.py +++ b/tamr_client/attributes/attribute_type.py @@ -5,9 +5,9 @@ import logging from typing import ClassVar, Tuple, Union +from tamr_client._types import JsonDict from tamr_client.attributes import subattribute from tamr_client.attributes.subattribute import SubAttribute -from tamr_client.types import JsonDict logger = logging.getLogger(__name__) diff --git a/tamr_client/attributes/subattribute.py b/tamr_client/attributes/subattribute.py index de93b88f..fe567ca3 100644 --- a/tamr_client/attributes/subattribute.py +++ b/tamr_client/attributes/subattribute.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Optional, TYPE_CHECKING -from tamr_client.types import JsonDict +from tamr_client._types import JsonDict if TYPE_CHECKING: from tamr_client.attributes.attribute_type import AttributeType diff --git a/tamr_client/dataset/dataframe.py b/tamr_client/dataset/dataframe.py index ad514820..1bd5f9b1 100644 --- a/tamr_client/dataset/dataframe.py +++ b/tamr_client/dataset/dataframe.py @@ -7,10 +7,10 @@ import pandas as pd +from tamr_client._types import JsonDict from tamr_client.dataset import record from tamr_client.dataset.dataset import Dataset from tamr_client.session import Session -from tamr_client.types import JsonDict class AmbiguousPrimaryKey(Exception): diff --git a/tamr_client/dataset/dataset.py b/tamr_client/dataset/dataset.py index 87bde613..c32728f8 100644 --- a/tamr_client/dataset/dataset.py +++ b/tamr_client/dataset/dataset.py @@ -6,10 +6,10 @@ from typing import Optional, Tuple, Union from tamr_client import response +from tamr_client._types import JsonDict from tamr_client.dataset.unified import UnifiedDataset from tamr_client.instance import Instance from tamr_client.session import Session -from tamr_client.types import JsonDict from tamr_client.url import URL diff --git a/tamr_client/dataset/record.py b/tamr_client/dataset/record.py index 6309d576..2d609ade 100644 --- a/tamr_client/dataset/record.py +++ b/tamr_client/dataset/record.py @@ -8,9 +8,9 @@ from typing import cast, Dict, IO, Iterable, Iterator, Optional from tamr_client import response +from tamr_client._types import JsonDict from tamr_client.dataset.dataset import AnyDataset, Dataset from tamr_client.session import Session -from tamr_client.types import JsonDict class PrimaryKeyNotFound(Exception): diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index a9bfa9cb..7931f770 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -6,11 +6,11 @@ from typing import Optional, Tuple from tamr_client import operation, response +from tamr_client._types import JsonDict from tamr_client.instance import Instance from tamr_client.operation import Operation from tamr_client.project import Project from tamr_client.session import Session -from tamr_client.types import JsonDict from tamr_client.url import URL diff --git a/tamr_client/mastering/project.py b/tamr_client/mastering/project.py index 4487939c..fd92fc41 100644 --- a/tamr_client/mastering/project.py +++ b/tamr_client/mastering/project.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Optional -from tamr_client.types import JsonDict +from tamr_client._types import JsonDict from tamr_client.url import URL diff --git a/tamr_client/operation.py b/tamr_client/operation.py index 22c0bdc5..0d7bb591 100644 --- a/tamr_client/operation.py +++ b/tamr_client/operation.py @@ -9,9 +9,9 @@ import requests from tamr_client import response +from tamr_client._types import JsonDict from tamr_client.instance import Instance from tamr_client.session import Session -from tamr_client.types import JsonDict from tamr_client.url import URL diff --git a/tamr_client/project.py b/tamr_client/project.py index f4faabbf..0b6ee72f 100644 --- a/tamr_client/project.py +++ b/tamr_client/project.py @@ -1,11 +1,11 @@ from typing import Union from tamr_client import response +from tamr_client._types import JsonDict from tamr_client.instance import Instance from tamr_client.mastering import project as mastering_project from tamr_client.mastering.project import Project as MasteringProject from tamr_client.session import Session -from tamr_client.types import JsonDict from tamr_client.url import URL diff --git a/tamr_client/response.py b/tamr_client/response.py index 2d603480..dad88aad 100644 --- a/tamr_client/response.py +++ b/tamr_client/response.py @@ -4,7 +4,7 @@ import requests -from tamr_client.types import JsonDict +from tamr_client._types import JsonDict logger = logging.getLogger(__name__) From a767e105c9333ddc3fbc28a873a5641a17eaf51b Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 29 Jun 2020 10:42:05 -0400 Subject: [PATCH 448/632] Move URL into _types package --- tamr_client/__init__.py | 6 ++---- tamr_client/_types/__init__.py | 1 + tamr_client/{ => _types}/url.py | 0 tamr_client/attributes/attribute.py | 3 +-- tamr_client/dataset/dataset.py | 3 +-- tamr_client/dataset/unified.py | 3 +-- tamr_client/mastering/project.py | 3 +-- tamr_client/operation.py | 3 +-- tamr_client/project.py | 3 +-- 9 files changed, 9 insertions(+), 16 deletions(-) rename tamr_client/{ => _types}/url.py (100%) diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 2110f4e3..0631bf70 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -13,6 +13,8 @@ # https://docs.python-guide.org/writing/logging/#logging-in-a-library logging.getLogger(__name__).addHandler(logging.NullHandler()) +from ._types import URL + # Import shortcuts ################## @@ -23,10 +25,6 @@ from tamr_client.instance import Instance from tamr_client import instance -# url -from tamr_client.url import URL -from tamr_client import url - # auth from tamr_client.auth import UsernamePasswordAuth diff --git a/tamr_client/_types/__init__.py b/tamr_client/_types/__init__.py index cd97b4cd..42c4997f 100644 --- a/tamr_client/_types/__init__.py +++ b/tamr_client/_types/__init__.py @@ -1 +1,2 @@ from .json import JsonDict +from .url import URL diff --git a/tamr_client/url.py b/tamr_client/_types/url.py similarity index 100% rename from tamr_client/url.py rename to tamr_client/_types/url.py diff --git a/tamr_client/attributes/attribute.py b/tamr_client/attributes/attribute.py index b39c31d1..9f6726ff 100644 --- a/tamr_client/attributes/attribute.py +++ b/tamr_client/attributes/attribute.py @@ -6,12 +6,11 @@ from typing import Optional, Tuple from tamr_client import response -from tamr_client._types import JsonDict +from tamr_client._types import JsonDict, URL from tamr_client.attributes import attribute_type, type_alias from tamr_client.attributes.attribute_type import AttributeType from tamr_client.dataset.dataset import Dataset from tamr_client.session import Session -from tamr_client.url import URL _RESERVED_NAMES = frozenset( diff --git a/tamr_client/dataset/dataset.py b/tamr_client/dataset/dataset.py index c32728f8..67c54e80 100644 --- a/tamr_client/dataset/dataset.py +++ b/tamr_client/dataset/dataset.py @@ -6,11 +6,10 @@ from typing import Optional, Tuple, Union from tamr_client import response -from tamr_client._types import JsonDict +from tamr_client._types import JsonDict, URL from tamr_client.dataset.unified import UnifiedDataset from tamr_client.instance import Instance from tamr_client.session import Session -from tamr_client.url import URL class NotFound(Exception): diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index 7931f770..22e299b4 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -6,12 +6,11 @@ from typing import Optional, Tuple from tamr_client import operation, response -from tamr_client._types import JsonDict +from tamr_client._types import JsonDict, URL from tamr_client.instance import Instance from tamr_client.operation import Operation from tamr_client.project import Project from tamr_client.session import Session -from tamr_client.url import URL class NotFound(Exception): diff --git a/tamr_client/mastering/project.py b/tamr_client/mastering/project.py index fd92fc41..2faa1173 100644 --- a/tamr_client/mastering/project.py +++ b/tamr_client/mastering/project.py @@ -1,8 +1,7 @@ from dataclasses import dataclass from typing import Optional -from tamr_client._types import JsonDict -from tamr_client.url import URL +from tamr_client._types import JsonDict, URL @dataclass(frozen=True) diff --git a/tamr_client/operation.py b/tamr_client/operation.py index 0d7bb591..feab9fcd 100644 --- a/tamr_client/operation.py +++ b/tamr_client/operation.py @@ -9,10 +9,9 @@ import requests from tamr_client import response -from tamr_client._types import JsonDict +from tamr_client._types import JsonDict, URL from tamr_client.instance import Instance from tamr_client.session import Session -from tamr_client.url import URL class NotFound(Exception): diff --git a/tamr_client/project.py b/tamr_client/project.py index 0b6ee72f..e6784732 100644 --- a/tamr_client/project.py +++ b/tamr_client/project.py @@ -1,12 +1,11 @@ from typing import Union from tamr_client import response -from tamr_client._types import JsonDict +from tamr_client._types import JsonDict, URL from tamr_client.instance import Instance from tamr_client.mastering import project as mastering_project from tamr_client.mastering.project import Project as MasteringProject from tamr_client.session import Session -from tamr_client.url import URL Project = Union[MasteringProject] From 2a3886a8b81bc9a944f5c3f381d9ed53a66123ec Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 29 Jun 2020 10:44:39 -0400 Subject: [PATCH 449/632] Include reusable VS Code settings --- .vscode/settings.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..254d37ee --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.linting.pylintEnabled": false, + "python.linting.flake8Enabled": true, + "python.linting.enabled": true, + "python.formatting.provider": "black", + "editor.formatOnSave": true +} \ No newline at end of file From 9db4898d62b0cd38cf21330820d575361d5a38b6 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 29 Jun 2020 10:52:30 -0400 Subject: [PATCH 450/632] Refactor primitive attribute types to be an Enum Avoids singletons and the class vs instance distinction --- tamr_client/attributes/attribute_type.py | 63 +++++-------------- .../tamr_client/attributes/test_attribute.py | 4 +- .../attributes/test_attribute_type.py | 6 +- 3 files changed, 21 insertions(+), 52 deletions(-) diff --git a/tamr_client/attributes/attribute_type.py b/tamr_client/attributes/attribute_type.py index bd840102..094329ad 100644 --- a/tamr_client/attributes/attribute_type.py +++ b/tamr_client/attributes/attribute_type.py @@ -2,6 +2,7 @@ See https://docs.tamr.com/reference#attribute-types """ from dataclasses import dataclass +from enum import Enum import logging from typing import ClassVar, Tuple, Union @@ -14,34 +15,16 @@ # primitive types ################# +PrimitiveType = Enum("PrimitiveType", ["BOOLEAN", "DOUBLE", "INT", "LONG", "STRING"]) -@dataclass(frozen=True) -class Boolean: - _tag: ClassVar[str] = "BOOLEAN" - - -@dataclass(frozen=True) -class Double: - _tag: ClassVar[str] = "DOUBLE" - - -@dataclass(frozen=True) -class Int: - _tag: ClassVar[str] = "INT" - - -@dataclass(frozen=True) -class Long: - _tag: ClassVar[str] = "LONG" +# aliases +DOUBLE = PrimitiveType.DOUBLE +BOOLEAN = PrimitiveType.BOOLEAN +INT = PrimitiveType.INT +LONG = PrimitiveType.LONG +STRING = PrimitiveType.STRING -@dataclass(frozen=True) -class String: - _tag: ClassVar[str] = "STRING" - - -PrimitiveType = Union[Boolean, Double, Int, Long, String] - # complex types ############### @@ -94,17 +77,12 @@ def from_json(data: JsonDict) -> AttributeType: if base_type is None: logger.error(f"JSON data: {repr(data)}") raise ValueError("Missing required field 'baseType'.") - if base_type == Boolean._tag: - return BOOLEAN - elif base_type == Double._tag: - return DOUBLE - elif base_type == Int._tag: - return INT - elif base_type == Long._tag: - return LONG - elif base_type == String._tag: - return STRING - elif base_type == Array._tag: + + for primitive in PrimitiveType: + if base_type == primitive.name: + return primitive + + if base_type == Array._tag: inner_type = data.get("innerType") if inner_type is None: logger.error(f"JSON data: {repr(data)}") @@ -135,8 +113,8 @@ def to_json(attr_type: AttributeType) -> JsonDict: Args: attr_type: Attribute type to serialize """ - if isinstance(attr_type, (Boolean, Double, Int, Long, String)): - return {"baseType": type(attr_type)._tag} + if isinstance(attr_type, PrimitiveType): + return {"baseType": attr_type.name} elif isinstance(attr_type, (Array, Map)): return { "baseType": type(attr_type)._tag, @@ -150,12 +128,3 @@ def to_json(attr_type: AttributeType) -> JsonDict: } else: raise TypeError(attr_type) - - -# Singletons - -BOOLEAN = Boolean() -DOUBLE = Double() -INT = Int() -LONG = Long() -STRING = String() diff --git a/tests/tamr_client/attributes/test_attribute.py b/tests/tamr_client/attributes/test_attribute.py index 8de37afd..35a53e27 100644 --- a/tests/tamr_client/attributes/test_attribute.py +++ b/tests/tamr_client/attributes/test_attribute.py @@ -40,7 +40,7 @@ def test_create(): tc.SubAttribute( name=str(i), is_nullable=True, - type=tc.attribute_type.Array(inner_type=tc.attribute_type.String()), + type=tc.attribute_type.Array(tc.attribute_type.STRING), ) for i in range(4) ] @@ -136,7 +136,7 @@ def test_from_dataset_all(): row_num = attrs[0] assert row_num.name == "RowNum" - assert isinstance(row_num.type, tc.attribute_type.String) + assert row_num.type == tc.attribute_type.STRING geom = attrs[1] assert geom.name == "geom" diff --git a/tests/tamr_client/attributes/test_attribute_type.py b/tests/tamr_client/attributes/test_attribute_type.py index 6843c347..4b61d923 100644 --- a/tests/tamr_client/attributes/test_attribute_type.py +++ b/tests/tamr_client/attributes/test_attribute_type.py @@ -11,13 +11,13 @@ def test_from_json(): assert isinstance(subattr, tc.SubAttribute) if i == 0: assert subattr.name == "point" - assert subattr.type == tc.attribute_type.Array(tc.attribute_type.Double()) + assert subattr.type == tc.attribute_type.Array(tc.attribute_type.DOUBLE) assert subattr.is_nullable assert subattr.description is None elif i == 1: assert subattr.name == "lineString" assert subattr.type == tc.attribute_type.Array( - tc.attribute_type.Array(tc.attribute_type.Double()) + tc.attribute_type.Array(tc.attribute_type.DOUBLE) ) assert subattr.is_nullable assert subattr.description is None @@ -25,7 +25,7 @@ def test_from_json(): assert subattr.name == "polygon" assert subattr.type == tc.attribute_type.Array( tc.attribute_type.Array( - tc.attribute_type.Array(tc.attribute_type.Double()) + tc.attribute_type.Array(tc.attribute_type.DOUBLE) ) ) assert subattr.is_nullable From 4ff349dfd99c5cb96cdb5487f44b0df875f05bc7 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 29 Jun 2020 11:50:52 -0400 Subject: [PATCH 451/632] Refactor attributes - Move types into _types package - Absorb aliases into _types/attribute.py module - Namepace attributes/attribute_type as attributes/type - Improve comments explaining forward references + recursive dependencies --- .flake8 | 1 + docs/beta/attributes.md | 2 +- docs/beta/attributes/attribute_type.rst | 38 ----- docs/beta/attributes/subattribute.rst | 5 + docs/beta/attributes/type.rst | 40 +++++ tamr_client/__init__.py | 9 +- tamr_client/_types/__init__.py | 17 ++ tamr_client/_types/attribute.py | 158 ++++++++++++++++++ tamr_client/attributes/__init__.py | 3 +- tamr_client/attributes/attribute.py | 33 +--- tamr_client/attributes/subattribute.py | 29 +--- .../attributes/{attribute_type.py => type.py} | 76 ++------- tamr_client/attributes/type_alias.py | 23 --- .../tamr_client/attributes/test_attribute.py | 8 +- .../{test_attribute_type.py => test_type.py} | 22 +-- 15 files changed, 263 insertions(+), 201 deletions(-) delete mode 100644 docs/beta/attributes/attribute_type.rst create mode 100644 docs/beta/attributes/type.rst create mode 100644 tamr_client/_types/attribute.py rename tamr_client/attributes/{attribute_type.py => type.py} (61%) delete mode 100644 tamr_client/attributes/type_alias.py rename tests/tamr_client/attributes/{test_attribute_type.py => test_type.py} (56%) diff --git a/.flake8 b/.flake8 index 5d97a7e9..5af61468 100644 --- a/.flake8 +++ b/.flake8 @@ -11,6 +11,7 @@ exclude = build,.venv,*.egg-info per-file-ignores = tamr_client/__init__.py:E402,F401,I100,I202 tamr_client/_types/__init__.py:F401 + tamr_client/attributes/__init__.py:F401 # flake8-import-order plugin import-order-style = google diff --git a/docs/beta/attributes.md b/docs/beta/attributes.md index 6f2ab055..008b1237 100644 --- a/docs/beta/attributes.md +++ b/docs/beta/attributes.md @@ -1,5 +1,5 @@ # Attributes * [Attribute](/beta/attributes/attribute) - * [Attribute Type](/beta/attributes/attribute_type) + * [Attribute Type](/beta/attributes/type) * [SubAttribute](/beta/attributes/subattribute) diff --git a/docs/beta/attributes/attribute_type.rst b/docs/beta/attributes/attribute_type.rst deleted file mode 100644 index 130f0839..00000000 --- a/docs/beta/attributes/attribute_type.rst +++ /dev/null @@ -1,38 +0,0 @@ -AttributeType -============= - -See https://docs.tamr.com/reference#attribute-types - -.. autodata:: tamr_client.attribute_type.BOOLEAN -.. autodata:: tamr_client.attribute_type.DOUBLE -.. autodata:: tamr_client.attribute_type.INT -.. autodata:: tamr_client.attribute_type.LONG -.. autodata:: tamr_client.attribute_type.STRING - -.. NOTE(pcattori): Manually write docs for complex attribute types - Complex types recursively reference other attribute types or subattributes - sphinx_autodoc_typehints cannot properly parse types recursively - -.. class:: tamr_client.attribute_type.Array(inner_type) - - :param inner_type: - :type inner_type: :class:`~tamr_client.AttributeType` - -.. class:: tamr_client.attribute_type.Map(inner_type) - - :param inner_type: - :type inner_type: :class:`~tamr_client.AttributeType` - -.. class:: tamr_client.attribute_type.Record(attributes) - - :param attributes: - :type attributes: :class:`~typing.Tuple` [:class:`~tamr_client.SubAttribute`] - -.. autofunction:: tamr_client.attribute_type.from_json -.. autofunction:: tamr_client.attribute_type.to_json - -Type aliases ------------- - -.. autodata:: tamr_client.attributes.type_alias.DEFAULT -.. autodata:: tamr_client.attributes.type_alias.GEOSPATIAL diff --git a/docs/beta/attributes/subattribute.rst b/docs/beta/attributes/subattribute.rst index 105bd7f2..658e0101 100644 --- a/docs/beta/attributes/subattribute.rst +++ b/docs/beta/attributes/subattribute.rst @@ -1,6 +1,11 @@ SubAttribute ============ +.. NOTE(pcattori): + `SubAttribute` has a recursive dependency on `AttributeType`. + `sphinx_autodoc_typehint` cannot handle recursive dependencies, + so reference docs are written manually + .. class:: tamr_client.SubAttribute(name, type, is_nullable, description=None) :param name: diff --git a/docs/beta/attributes/type.rst b/docs/beta/attributes/type.rst new file mode 100644 index 00000000..8758cdc5 --- /dev/null +++ b/docs/beta/attributes/type.rst @@ -0,0 +1,40 @@ +AttributeType +============= + +See https://docs.tamr.com/reference#attribute-types + +.. autodata:: tamr_client.attributes.type.BOOLEAN +.. autodata:: tamr_client.attributes.type.DOUBLE +.. autodata:: tamr_client.attributes.type.INT +.. autodata:: tamr_client.attributes.type.LONG +.. autodata:: tamr_client.attributes.type.STRING + +.. autodata:: tamr_client.attributes.type.DEFAULT +.. autodata:: tamr_client.attributes.type.GEOSPATIAL + + +.. NOTE(pcattori): + `Array` has a recursive dependency on `AttributeType`. + `sphinx_autodoc_typehint` cannot handle recursive dependencies, + so reference docs are written manually + +.. class:: tamr_client.attributes.type.Array(inner_type) + + :param inner_type: + :type inner_type: :class:`~tamr_client.AttributeType` + +.. NOTE(pcattori): + `Map` has a recursive dependency on `AttributeType`. + `sphinx_autodoc_typehint` cannot handle recursive dependencies, + so reference docs are written manually + +.. class:: tamr_client.attributes.type.Map(inner_type) + + :param inner_type: + :type inner_type: :class:`~tamr_client.AttributeType` + + +.. autoclass:: tamr_client.attributes.type.Record + +.. autofunction:: tamr_client.attributes.type.from_json +.. autofunction:: tamr_client.attributes.type.to_json diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 0631bf70..c2f958d1 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -13,7 +13,7 @@ # https://docs.python-guide.org/writing/logging/#logging-in-a-library logging.getLogger(__name__).addHandler(logging.NullHandler()) -from ._types import URL +from ._types import Attribute, AttributeType, SubAttribute, URL # Import shortcuts ################## @@ -45,16 +45,9 @@ from tamr_client.dataset import dataframe # attributes -from tamr_client.attributes.subattribute import SubAttribute from tamr_client.attributes import subattribute -from tamr_client.attributes.attribute_type import AttributeType -from tamr_client.attributes import attribute_type - -import tamr_client.attributes.type_alias - from tamr_client.attributes.attribute import ( - Attribute, AttributeExists, AttributeNotFound, ReservedAttributeName, diff --git a/tamr_client/_types/__init__.py b/tamr_client/_types/__init__.py index 42c4997f..a572eec9 100644 --- a/tamr_client/_types/__init__.py +++ b/tamr_client/_types/__init__.py @@ -1,2 +1,19 @@ +from .attribute import ( + Array, + Attribute, + AttributeType, + BOOLEAN, + ComplexType, + DEFAULT, + DOUBLE, + GEOSPATIAL, + INT, + LONG, + Map, + PrimitiveType, + Record, + STRING, + SubAttribute, +) from .json import JsonDict from .url import URL diff --git a/tamr_client/_types/attribute.py b/tamr_client/_types/attribute.py new file mode 100644 index 00000000..f88391f2 --- /dev/null +++ b/tamr_client/_types/attribute.py @@ -0,0 +1,158 @@ +""" +This module includes: +- SubAttribute +- AttributeType +- Attribute + +The definition order is chosen to minimize the number of forward references. +See https://www.python.org/dev/peps/pep-0484/#forward-references + +Forward references are necessary because +- `SubAttribute` and `AttributeType` recursively depend on each other +- `Array` and `Map` have `AttributeType` fields but are themselves `AttributeType`s +""" + +from dataclasses import dataclass +from enum import Enum +from typing import ClassVar, Optional, Tuple, Union + +from tamr_client._types.url import URL + + +# sub attribute +############### + + +@dataclass(frozen=True) +class SubAttribute: + """An attribute which is itself a property of another attribute. + + See https://docs.tamr.com/reference#attribute-types + + NOTE: + `sphinx_autodoc_typehints` cannot handle forward reference to `AttributeType`, + so reference docs are written manually for this type + + Args: + name: Name of sub-attribute + description: Description of sub-attribute + type: See https://docs.tamr.com/reference#attribute-types + is_nullable: If this sub-attribute can be null + """ + + name: str + type: "AttributeType" + is_nullable: bool + description: Optional[str] = None + + +# attribute types +################# + +# primitive types + +PrimitiveType = Enum("PrimitiveType", ["BOOLEAN", "DOUBLE", "INT", "LONG", "STRING"]) + +# primitive type aliases +DOUBLE = PrimitiveType.DOUBLE +BOOLEAN = PrimitiveType.BOOLEAN +INT = PrimitiveType.INT +LONG = PrimitiveType.LONG +STRING = PrimitiveType.STRING + +# complex types + + +@dataclass(frozen=True) +class Array: + """See https://docs.tamr.com/reference#attribute-types + + NOTE: + `sphinx_autodoc_typehints` cannot handle forward reference to `AttributeType`, + so reference docs are written manually for this type + + Args: + inner_type + """ + + _tag: ClassVar[str] = "ARRAY" + inner_type: "AttributeType" + + +@dataclass(frozen=True) +class Map: + """See https://docs.tamr.com/reference#attribute-types + + NOTE: + `sphinx_autodoc_typehints` cannot handle forward reference to `AttributeType`, + so reference docs are written manually for this type + + Args: + inner_type + """ + + _tag: ClassVar[str] = "MAP" + inner_type: "AttributeType" + + +@dataclass(frozen=True) +class Record: + """See https://docs.tamr.com/reference#attribute-types + + Args: + attributes + """ + + _tag: ClassVar[str] = "RECORD" + attributes: Tuple[SubAttribute, ...] + + +ComplexType = Union[Array, Map, Record] + + +AttributeType = Union[PrimitiveType, ComplexType] + +# complex type aliases +DEFAULT: AttributeType = Array(STRING) +GEOSPATIAL: AttributeType = Record( + attributes=( + SubAttribute(name="point", is_nullable=True, type=Array(DOUBLE)), + SubAttribute(name="multiPoint", is_nullable=True, type=Array(Array(DOUBLE))), + SubAttribute(name="lineString", is_nullable=True, type=Array(Array(DOUBLE))), + SubAttribute( + name="multiLineString", is_nullable=True, type=Array(Array(Array(DOUBLE))) + ), + SubAttribute( + name="polygon", is_nullable=True, type=Array(Array(Array(DOUBLE))) + ), + SubAttribute( + name="multiPolygon", + is_nullable=True, + type=Array(Array(Array(Array(DOUBLE)))), + ), + ) +) + +# attribute +########### + + +@dataclass(frozen=True) +class Attribute: + """A Tamr Attribute. + + See https://docs.tamr.com/reference#attribute-types + + Args: + url + name + type + is_nullable + description + """ + + url: URL + name: str + type: AttributeType + is_nullable: bool + description: Optional[str] = None diff --git a/tamr_client/attributes/__init__.py b/tamr_client/attributes/__init__.py index b2df60f9..7f3d0cd9 100644 --- a/tamr_client/attributes/__init__.py +++ b/tamr_client/attributes/__init__.py @@ -1,2 +1 @@ -# This __init__.py file is necessary for `mypy --package` -# See https://github.com/python/mypy/issues/5759 +from . import type diff --git a/tamr_client/attributes/attribute.py b/tamr_client/attributes/attribute.py index 9f6726ff..6576a5e3 100644 --- a/tamr_client/attributes/attribute.py +++ b/tamr_client/attributes/attribute.py @@ -2,13 +2,12 @@ See https://docs.tamr.com/reference/attribute-types """ from copy import deepcopy -from dataclasses import dataclass, field, replace +from dataclasses import replace from typing import Optional, Tuple from tamr_client import response -from tamr_client._types import JsonDict, URL -from tamr_client.attributes import attribute_type, type_alias -from tamr_client.attributes.attribute_type import AttributeType +from tamr_client._types import Attribute, AttributeType, JsonDict, URL +from tamr_client.attributes import type as attribute_type from tamr_client.dataset.dataset import Dataset from tamr_client.session import Session @@ -52,27 +51,6 @@ class ReservedAttributeName(Exception): pass -@dataclass(frozen=True) -class Attribute: - """A Tamr Attribute. - - See https://docs.tamr.com/reference#attribute-types - - Args: - url - name - type - description - """ - - url: URL - name: str - type: AttributeType - is_nullable: bool - _json: JsonDict = field(compare=False, repr=False) - description: Optional[str] = None - - def from_resource_id(session: Session, dataset: Dataset, id: str) -> Attribute: """Get attribute by resource ID @@ -125,7 +103,6 @@ def _from_json(url: URL, data: JsonDict) -> Attribute: description=cp.get("description"), is_nullable=cp["isNullable"], type=attribute_type.from_json(cp["type"]), - _json=cp, ) @@ -179,7 +156,7 @@ def create( *, name: str, is_nullable: bool, - type: AttributeType = type_alias.DEFAULT, + type: AttributeType = attribute_type.DEFAULT, description: Optional[str] = None, ) -> Attribute: """Create an attribute @@ -222,7 +199,7 @@ def _create( *, name: str, is_nullable: bool, - type: AttributeType = type_alias.DEFAULT, + type: AttributeType = attribute_type.DEFAULT, description: Optional[str] = None, ) -> Attribute: """Same as `tc.attribute.create`, but does not check for reserved attribute diff --git a/tamr_client/attributes/subattribute.py b/tamr_client/attributes/subattribute.py index fe567ca3..5f683b4b 100644 --- a/tamr_client/attributes/subattribute.py +++ b/tamr_client/attributes/subattribute.py @@ -2,32 +2,9 @@ """ from copy import deepcopy -from dataclasses import dataclass -from typing import Optional, TYPE_CHECKING -from tamr_client._types import JsonDict - -if TYPE_CHECKING: - from tamr_client.attributes.attribute_type import AttributeType - - -@dataclass(frozen=True) -class SubAttribute: - """An attribute which is itself a property of another attribute. - - See https://docs.tamr.com/reference#attribute-types - - Args: - name: Name of sub-attribute - description: Description of sub-attribute - type: See https://docs.tamr.com/reference#attribute-types - is_nullable: If this sub-attribute can be null - """ - - name: str - type: "AttributeType" - is_nullable: bool - description: Optional[str] = None +from tamr_client._types import JsonDict, SubAttribute +from tamr_client.attributes import type as attribute_type def from_json(data: JsonDict) -> SubAttribute: @@ -36,7 +13,6 @@ def from_json(data: JsonDict) -> SubAttribute: Args: data: JSON data received from Tamr server. """ - from tamr_client.attributes import attribute_type cp = deepcopy(data) d = {} @@ -52,7 +28,6 @@ def to_json(subattr: SubAttribute) -> JsonDict: Args: subattr: SubAttribute to serialize """ - from tamr_client.attributes import attribute_type d = { "name": subattr.name, diff --git a/tamr_client/attributes/attribute_type.py b/tamr_client/attributes/type.py similarity index 61% rename from tamr_client/attributes/attribute_type.py rename to tamr_client/attributes/type.py index 094329ad..3e7ba802 100644 --- a/tamr_client/attributes/attribute_type.py +++ b/tamr_client/attributes/type.py @@ -1,71 +1,29 @@ """ See https://docs.tamr.com/reference#attribute-types """ -from dataclasses import dataclass -from enum import Enum import logging -from typing import ClassVar, Tuple, Union -from tamr_client._types import JsonDict +from tamr_client._types import ( + Array, + AttributeType, + JsonDict, + Map, + PrimitiveType, + Record, +) +from tamr_client._types import ( # noqa: F401 + BOOLEAN, + DEFAULT, + DOUBLE, + GEOSPATIAL, + INT, + LONG, + STRING, +) from tamr_client.attributes import subattribute -from tamr_client.attributes.subattribute import SubAttribute logger = logging.getLogger(__name__) -# primitive types -################# - -PrimitiveType = Enum("PrimitiveType", ["BOOLEAN", "DOUBLE", "INT", "LONG", "STRING"]) - -# aliases -DOUBLE = PrimitiveType.DOUBLE -BOOLEAN = PrimitiveType.BOOLEAN -INT = PrimitiveType.INT -LONG = PrimitiveType.LONG -STRING = PrimitiveType.STRING - - -# complex types -############### - - -@dataclass(frozen=True) -class Array: - """See https://docs.tamr.com/reference#attribute-types""" - - # NOTE(pcattori) sphinx_autodoc_typehints cannot handle recursive reference - # docstring written manually - _tag: ClassVar[str] = "ARRAY" - inner_type: "AttributeType" - - -@dataclass(frozen=True) -class Map: - """See https://docs.tamr.com/reference#attribute-types""" - - # NOTE(pcattori): sphinx_autodoc_typehints cannot handle recursive reference - # docstring written manually - _tag: ClassVar[str] = "MAP" - inner_type: "AttributeType" - - -@dataclass(frozen=True) -class Record: - """See https://docs.tamr.com/reference#attribute-types""" - - # NOTE(pcattori) sphinx_autodoc_typehints cannot handle recursive reference - # docstring written manually - _tag: ClassVar[str] = "RECORD" - attributes: Tuple[SubAttribute, ...] - - -ComplexType = Union[Array, Map, Record] - -# attribute type -################ - -AttributeType = Union[PrimitiveType, ComplexType] - def from_json(data: JsonDict) -> AttributeType: """Make an attribute type from JSON data (deserialize) diff --git a/tamr_client/attributes/type_alias.py b/tamr_client/attributes/type_alias.py deleted file mode 100644 index fa418bfb..00000000 --- a/tamr_client/attributes/type_alias.py +++ /dev/null @@ -1,23 +0,0 @@ -from tamr_client.attributes.attribute_type import Array, DOUBLE, Record, STRING -from tamr_client.attributes.subattribute import SubAttribute - -DEFAULT: Array = Array(STRING) - -GEOSPATIAL: Record = Record( - attributes=( - SubAttribute(name="point", is_nullable=True, type=Array(DOUBLE)), - SubAttribute(name="multiPoint", is_nullable=True, type=Array(Array(DOUBLE))), - SubAttribute(name="lineString", is_nullable=True, type=Array(Array(DOUBLE))), - SubAttribute( - name="multiLineString", is_nullable=True, type=Array(Array(Array(DOUBLE))) - ), - SubAttribute( - name="polygon", is_nullable=True, type=Array(Array(Array(DOUBLE))) - ), - SubAttribute( - name="multiPolygon", - is_nullable=True, - type=Array(Array(Array(Array(DOUBLE)))), - ), - ) -) diff --git a/tests/tamr_client/attributes/test_attribute.py b/tests/tamr_client/attributes/test_attribute.py index 35a53e27..d9d34c41 100644 --- a/tests/tamr_client/attributes/test_attribute.py +++ b/tests/tamr_client/attributes/test_attribute.py @@ -40,7 +40,7 @@ def test_create(): tc.SubAttribute( name=str(i), is_nullable=True, - type=tc.attribute_type.Array(tc.attribute_type.STRING), + type=tc.attributes.type.Array(tc.attributes.type.STRING), ) for i in range(4) ] @@ -55,7 +55,7 @@ def test_create(): dataset, name="attr", is_nullable=False, - type=tc.attribute_type.Record(attributes=attrs), + type=tc.attributes.type.Record(attributes=attrs), ) assert attr == tc.attribute._from_json(url, attr_json) @@ -136,11 +136,11 @@ def test_from_dataset_all(): row_num = attrs[0] assert row_num.name == "RowNum" - assert row_num.type == tc.attribute_type.STRING + assert row_num.type == tc.attributes.type.STRING geom = attrs[1] assert geom.name == "geom" - assert isinstance(geom.type, tc.attribute_type.Record) + assert isinstance(geom.type, tc.attributes.type.Record) @responses.activate diff --git a/tests/tamr_client/attributes/test_attribute_type.py b/tests/tamr_client/attributes/test_type.py similarity index 56% rename from tests/tamr_client/attributes/test_attribute_type.py rename to tests/tamr_client/attributes/test_type.py index 4b61d923..25d26915 100644 --- a/tests/tamr_client/attributes/test_attribute_type.py +++ b/tests/tamr_client/attributes/test_type.py @@ -4,28 +4,28 @@ def test_from_json(): geom_json = utils.load_json("attributes.json")[1] - geom_type = tc.attribute_type.from_json(geom_json["type"]) - assert isinstance(geom_type, tc.attribute_type.Record) + geom_type = tc.attributes.type.from_json(geom_json["type"]) + assert isinstance(geom_type, tc.attributes.type.Record) for i, subattr in enumerate(geom_type.attributes): assert isinstance(subattr, tc.SubAttribute) if i == 0: assert subattr.name == "point" - assert subattr.type == tc.attribute_type.Array(tc.attribute_type.DOUBLE) + assert subattr.type == tc.attributes.type.Array(tc.attributes.type.DOUBLE) assert subattr.is_nullable assert subattr.description is None elif i == 1: assert subattr.name == "lineString" - assert subattr.type == tc.attribute_type.Array( - tc.attribute_type.Array(tc.attribute_type.DOUBLE) + assert subattr.type == tc.attributes.type.Array( + tc.attributes.type.Array(tc.attributes.type.DOUBLE) ) assert subattr.is_nullable assert subattr.description is None elif i == 2: assert subattr.name == "polygon" - assert subattr.type == tc.attribute_type.Array( - tc.attribute_type.Array( - tc.attribute_type.Array(tc.attribute_type.DOUBLE) + assert subattr.type == tc.attributes.type.Array( + tc.attributes.type.Array( + tc.attributes.type.Array(tc.attributes.type.DOUBLE) ) ) assert subattr.is_nullable @@ -36,7 +36,7 @@ def test_json(): attrs_json = utils.load_json("attributes.json") for attr_json in attrs_json: attr_type_json = attr_json["type"] - attr_type = tc.attribute_type.from_json(attr_type_json) - assert attr_type == tc.attribute_type.from_json( - tc.attribute_type.to_json(attr_type) + attr_type = tc.attributes.type.from_json(attr_type_json) + assert attr_type == tc.attributes.type.from_json( + tc.attributes.type.to_json(attr_type) ) From 2f59e3c8b9af370f6b50ee59ed5ab90e9974ecbc Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 29 Jun 2020 12:00:40 -0400 Subject: [PATCH 452/632] Namespace attributes/subattribute.py as attributes/sub.py --- docs/beta/attributes/subattribute.rst | 4 ++-- tamr_client/__init__.py | 2 -- tamr_client/attributes/__init__.py | 1 + tamr_client/attributes/{subattribute.py => sub.py} | 0 tamr_client/attributes/type.py | 8 +++----- 5 files changed, 6 insertions(+), 9 deletions(-) rename tamr_client/attributes/{subattribute.py => sub.py} (100%) diff --git a/docs/beta/attributes/subattribute.rst b/docs/beta/attributes/subattribute.rst index 658e0101..c3acfdf2 100644 --- a/docs/beta/attributes/subattribute.rst +++ b/docs/beta/attributes/subattribute.rst @@ -17,5 +17,5 @@ SubAttribute :param description: :type description: :class:`~typing.Optional` [:class:`str`] -.. autofunction:: tamr_client.subattribute.from_json -.. autofunction:: tamr_client.subattribute.to_json +.. autofunction:: tamr_client.attributes.sub.from_json +.. autofunction:: tamr_client.attributes.sub.to_json diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index c2f958d1..5d8082a7 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -45,8 +45,6 @@ from tamr_client.dataset import dataframe # attributes -from tamr_client.attributes import subattribute - from tamr_client.attributes.attribute import ( AttributeExists, AttributeNotFound, diff --git a/tamr_client/attributes/__init__.py b/tamr_client/attributes/__init__.py index 7f3d0cd9..3150171d 100644 --- a/tamr_client/attributes/__init__.py +++ b/tamr_client/attributes/__init__.py @@ -1 +1,2 @@ +from . import sub from . import type diff --git a/tamr_client/attributes/subattribute.py b/tamr_client/attributes/sub.py similarity index 100% rename from tamr_client/attributes/subattribute.py rename to tamr_client/attributes/sub.py diff --git a/tamr_client/attributes/type.py b/tamr_client/attributes/type.py index 3e7ba802..6a7188b1 100644 --- a/tamr_client/attributes/type.py +++ b/tamr_client/attributes/type.py @@ -20,7 +20,7 @@ LONG, STRING, ) -from tamr_client.attributes import subattribute +from tamr_client.attributes import sub logger = logging.getLogger(__name__) @@ -57,9 +57,7 @@ def from_json(data: JsonDict) -> AttributeType: if attributes is None: logger.error(f"JSON data: {repr(data)}") raise ValueError("Missing required field 'attributes' for Record type.") - return Record( - attributes=tuple([subattribute.from_json(attr) for attr in attributes]) - ) + return Record(attributes=tuple([sub.from_json(attr) for attr in attributes])) else: logger.error(f"JSON data: {repr(data)}") raise ValueError(f"Unrecognized 'baseType': {base_type}") @@ -82,7 +80,7 @@ def to_json(attr_type: AttributeType) -> JsonDict: return { "baseType": type(attr_type)._tag, - "attributes": [subattribute.to_json(attr) for attr in attr_type.attributes], + "attributes": [sub.to_json(attr) for attr in attr_type.attributes], } else: raise TypeError(attr_type) From 10a8945bdb52260a2de5f46a15a84d31292e0f2d Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 29 Jun 2020 12:35:05 -0400 Subject: [PATCH 453/632] Namespace attribute exceptions --- docs/beta/attributes/attribute.rst | 6 ++-- tamr_client/__init__.py | 5 ---- tamr_client/attributes/__init__.py | 1 + tamr_client/attributes/attribute.py | 28 +++++++++---------- .../tamr_client/attributes/test_attribute.py | 10 +++---- 5 files changed, 23 insertions(+), 27 deletions(-) diff --git a/docs/beta/attributes/attribute.rst b/docs/beta/attributes/attribute.rst index 09a039bb..372a79ff 100644 --- a/docs/beta/attributes/attribute.rst +++ b/docs/beta/attributes/attribute.rst @@ -13,11 +13,11 @@ Attribute Exceptions ---------- -.. autoclass:: tamr_client.ReservedAttributeName +.. autoclass:: tamr_client.attributes.AlreadyExists :no-inherited-members: -.. autoclass:: tamr_client.AttributeExists +.. autoclass:: tamr_client.attributes.NotFound :no-inherited-members: -.. autoclass:: tamr_client.AttributeNotFound +.. autoclass:: tamr_client.attributes.ReservedName :no-inherited-members: diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 5d8082a7..036275ba 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -45,11 +45,6 @@ from tamr_client.dataset import dataframe # attributes -from tamr_client.attributes.attribute import ( - AttributeExists, - AttributeNotFound, - ReservedAttributeName, -) from tamr_client.attributes import attribute from tamr_client import mastering diff --git a/tamr_client/attributes/__init__.py b/tamr_client/attributes/__init__.py index 3150171d..c4e38595 100644 --- a/tamr_client/attributes/__init__.py +++ b/tamr_client/attributes/__init__.py @@ -1,2 +1,3 @@ from . import sub from . import type +from .attribute import AlreadyExists, NotFound, ReservedName diff --git a/tamr_client/attributes/attribute.py b/tamr_client/attributes/attribute.py index 6576a5e3..8e80f854 100644 --- a/tamr_client/attributes/attribute.py +++ b/tamr_client/attributes/attribute.py @@ -31,7 +31,7 @@ ) -class AttributeNotFound(Exception): +class NotFound(Exception): """Raised when referencing (e.g. updating or deleting) an attribute that does not exist on the server. """ @@ -39,13 +39,13 @@ class AttributeNotFound(Exception): pass -class AttributeExists(Exception): +class AlreadyExists(Exception): """Raised when trying to create an attribute that already exists on the server""" pass -class ReservedAttributeName(Exception): +class ReservedName(Exception): """Raised when attempting to create an attribute with a reserved name""" pass @@ -61,7 +61,7 @@ def from_resource_id(session: Session, dataset: Dataset, id: str) -> Attribute: id: Attribute ID Raises: - AttributeNotFound: If no attribute could be found at the specified URL. + attributes.NotFound: If no attribute could be found at the specified URL. Corresponds to a 404 HTTP error. requests.HTTPError: If any other HTTP error is encountered. """ @@ -78,13 +78,13 @@ def _from_url(session: Session, url: URL) -> Attribute: url: Attribute URL Raises: - AttributeNotFound: If no attribute could be found at the specified URL. + attributes.NotFound: If no attribute could be found at the specified URL. Corresponds to a 404 HTTP error. requests.HTTPError: If any other HTTP error is encountered. """ r = session.get(str(url)) if r.status_code == 404: - raise AttributeNotFound(str(url)) + raise NotFound(str(url)) data = response.successful(r).json() return _from_json(url, data) @@ -175,13 +175,13 @@ def create( The newly created attribute Raises: - ReservedAttributeName: If attribute name is reserved. - AttributeExists: If an attribute already exists at the specified URL. + ReservedName: If attribute name is reserved. + AlreadyExists: If an attribute already exists at the specified URL. Corresponds to a 409 HTTP error. requests.HTTPError: If any other HTTP error is encountered. """ if name in _RESERVED_NAMES: - raise ReservedAttributeName(name) + raise ReservedName(name) return _create( session, @@ -218,7 +218,7 @@ def _create( r = session.post(str(attrs_url), json=body) if r.status_code == 409: - raise AttributeExists(str(url)) + raise AlreadyExists(str(url)) data = response.successful(r).json() return _from_json(url, data) @@ -239,14 +239,14 @@ def update( The newly updated attribute Raises: - AttributeNotFound: If no attribute could be found at the specified URL. + attributes.NotFound: If no attribute could be found at the specified URL. Corresponds to a 404 HTTP error. requests.HTTPError: If any other HTTP error is encountered. """ updates = {"description": description} r = session.put(str(attribute.url), json=updates) if r.status_code == 404: - raise AttributeNotFound(str(attribute.url)) + raise NotFound(str(attribute.url)) data = response.successful(r).json() return _from_json(attribute.url, data) @@ -260,11 +260,11 @@ def delete(session: Session, attribute: Attribute): attribute: Existing attribute to delete Raises: - AttributeNotFound: If no attribute could be found at the specified URL. + attributes.NotFound: If no attribute could be found at the specified URL. Corresponds to a 404 HTTP error. requests.HTTPError: If any other HTTP error is encountered. """ r = session.delete(str(attribute.url)) if r.status_code == 404: - raise AttributeNotFound(str(attribute.url)) + raise NotFound(str(attribute.url)) response.successful(r) diff --git a/tests/tamr_client/attributes/test_attribute.py b/tests/tamr_client/attributes/test_attribute.py index d9d34c41..ed340d0d 100644 --- a/tests/tamr_client/attributes/test_attribute.py +++ b/tests/tamr_client/attributes/test_attribute.py @@ -111,7 +111,7 @@ def test_from_resource_id_attribute_not_found(): url = replace(dataset.url, path=dataset.url.path + "/attributes/attr") responses.add(responses.GET, str(url), status=404) - with pytest.raises(tc.AttributeNotFound): + with pytest.raises(tc.attributes.NotFound): tc.attribute.from_resource_id(s, dataset, "attr") @@ -119,7 +119,7 @@ def test_create_reserved_attribute_name(): s = utils.session() dataset = utils.dataset() - with pytest.raises(tc.ReservedAttributeName): + with pytest.raises(tc.attributes.ReservedName): tc.attribute.create(s, dataset, name="clusterId", is_nullable=False) @@ -150,7 +150,7 @@ def test_create_attribute_exists(): url = replace(dataset.url, path=dataset.url.path + "/attributes") responses.add(responses.POST, str(url), status=409) - with pytest.raises(tc.AttributeExists): + with pytest.raises(tc.attributes.AlreadyExists): tc.attribute.create(s, dataset, name="attr", is_nullable=False) @@ -163,7 +163,7 @@ def test_update_attribute_not_found(): attr = tc.attribute._from_json(url, attr_json) responses.add(responses.PUT, str(attr.url), status=404) - with pytest.raises(tc.AttributeNotFound): + with pytest.raises(tc.attributes.NotFound): tc.attribute.update(s, attr) @@ -176,5 +176,5 @@ def test_delete_attribute_not_found(): attr = tc.attribute._from_json(url, attr_json) responses.add(responses.PUT, str(attr.url), status=404) - with pytest.raises(tc.AttributeNotFound): + with pytest.raises(tc.attributes.NotFound): attr = tc.attribute.update(s, attr) From 83497d4678cc3047af1211a15da6eabbfe0a7979 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 29 Jun 2020 13:03:10 -0400 Subject: [PATCH 454/632] Rename tc.attributes to tc.attribute Also, rename remaining subattribute to sub --- .flake8 | 2 +- docs/beta.md | 2 +- docs/beta/attribute.md | 5 ++++ .../{attributes => attribute}/attribute.rst | 6 ++--- .../subattribute.rst => attribute/sub.rst} | 4 ++-- docs/beta/{attributes => attribute}/type.rst | 24 +++++++++---------- docs/beta/attributes.md | 5 ---- tamr_client/__init__.py | 2 +- tamr_client/attribute/__init__.py | 14 +++++++++++ .../attribute.py => attribute/_attribute.py} | 22 ++++++++--------- tamr_client/{attributes => attribute}/sub.py | 2 +- tamr_client/{attributes => attribute}/type.py | 2 +- tamr_client/attributes/__init__.py | 3 --- .../test_attribute.py | 18 +++++++------- .../{attributes => attribute}/test_type.py | 22 ++++++++--------- 15 files changed, 72 insertions(+), 61 deletions(-) create mode 100644 docs/beta/attribute.md rename docs/beta/{attributes => attribute}/attribute.rst (75%) rename docs/beta/{attributes/subattribute.rst => attribute/sub.rst} (83%) rename docs/beta/{attributes => attribute}/type.rst (50%) delete mode 100644 docs/beta/attributes.md create mode 100644 tamr_client/attribute/__init__.py rename tamr_client/{attributes/attribute.py => attribute/_attribute.py} (94%) rename tamr_client/{attributes => attribute}/sub.py (93%) rename tamr_client/{attributes => attribute}/type.py (98%) delete mode 100644 tamr_client/attributes/__init__.py rename tests/tamr_client/{attributes => attribute}/test_attribute.py (90%) rename tests/tamr_client/{attributes => attribute}/test_type.py (56%) diff --git a/.flake8 b/.flake8 index 5af61468..fefae2b8 100644 --- a/.flake8 +++ b/.flake8 @@ -11,7 +11,7 @@ exclude = build,.venv,*.egg-info per-file-ignores = tamr_client/__init__.py:E402,F401,I100,I202 tamr_client/_types/__init__.py:F401 - tamr_client/attributes/__init__.py:F401 + tamr_client/attribute/__init__.py:F401 # flake8-import-order plugin import-order-style = google diff --git a/docs/beta.md b/docs/beta.md index 2ebf658f..7a5feeb4 100644 --- a/docs/beta.md +++ b/docs/beta.md @@ -5,7 +5,7 @@ ## Reference - * [Attributes](beta/attributes) + * [Attribute](beta/attribute) * [Auth](beta/auth) * [Dataset](beta/dataset) * [Instance](beta/instance) diff --git a/docs/beta/attribute.md b/docs/beta/attribute.md new file mode 100644 index 00000000..169722f0 --- /dev/null +++ b/docs/beta/attribute.md @@ -0,0 +1,5 @@ +# Attribute + + * [Attribute](/beta/attribute/attribute) + * [Attribute Type](/beta/attribute/type) + * [SubAttribute](/beta/attribute/sub) diff --git a/docs/beta/attributes/attribute.rst b/docs/beta/attribute/attribute.rst similarity index 75% rename from docs/beta/attributes/attribute.rst rename to docs/beta/attribute/attribute.rst index 372a79ff..d8a78025 100644 --- a/docs/beta/attributes/attribute.rst +++ b/docs/beta/attribute/attribute.rst @@ -13,11 +13,11 @@ Attribute Exceptions ---------- -.. autoclass:: tamr_client.attributes.AlreadyExists +.. autoclass:: tamr_client.attribute.AlreadyExists :no-inherited-members: -.. autoclass:: tamr_client.attributes.NotFound +.. autoclass:: tamr_client.attribute.NotFound :no-inherited-members: -.. autoclass:: tamr_client.attributes.ReservedName +.. autoclass:: tamr_client.attribute.ReservedName :no-inherited-members: diff --git a/docs/beta/attributes/subattribute.rst b/docs/beta/attribute/sub.rst similarity index 83% rename from docs/beta/attributes/subattribute.rst rename to docs/beta/attribute/sub.rst index c3acfdf2..38ddecd7 100644 --- a/docs/beta/attributes/subattribute.rst +++ b/docs/beta/attribute/sub.rst @@ -17,5 +17,5 @@ SubAttribute :param description: :type description: :class:`~typing.Optional` [:class:`str`] -.. autofunction:: tamr_client.attributes.sub.from_json -.. autofunction:: tamr_client.attributes.sub.to_json +.. autofunction:: tamr_client.attribute.sub.from_json +.. autofunction:: tamr_client.attribute.sub.to_json diff --git a/docs/beta/attributes/type.rst b/docs/beta/attribute/type.rst similarity index 50% rename from docs/beta/attributes/type.rst rename to docs/beta/attribute/type.rst index 8758cdc5..a157e62d 100644 --- a/docs/beta/attributes/type.rst +++ b/docs/beta/attribute/type.rst @@ -3,14 +3,14 @@ AttributeType See https://docs.tamr.com/reference#attribute-types -.. autodata:: tamr_client.attributes.type.BOOLEAN -.. autodata:: tamr_client.attributes.type.DOUBLE -.. autodata:: tamr_client.attributes.type.INT -.. autodata:: tamr_client.attributes.type.LONG -.. autodata:: tamr_client.attributes.type.STRING +.. autodata:: tamr_client.attribute.type.BOOLEAN +.. autodata:: tamr_client.attribute.type.DOUBLE +.. autodata:: tamr_client.attribute.type.INT +.. autodata:: tamr_client.attribute.type.LONG +.. autodata:: tamr_client.attribute.type.STRING -.. autodata:: tamr_client.attributes.type.DEFAULT -.. autodata:: tamr_client.attributes.type.GEOSPATIAL +.. autodata:: tamr_client.attribute.type.DEFAULT +.. autodata:: tamr_client.attribute.type.GEOSPATIAL .. NOTE(pcattori): @@ -18,7 +18,7 @@ See https://docs.tamr.com/reference#attribute-types `sphinx_autodoc_typehint` cannot handle recursive dependencies, so reference docs are written manually -.. class:: tamr_client.attributes.type.Array(inner_type) +.. class:: tamr_client.attribute.type.Array(inner_type) :param inner_type: :type inner_type: :class:`~tamr_client.AttributeType` @@ -28,13 +28,13 @@ See https://docs.tamr.com/reference#attribute-types `sphinx_autodoc_typehint` cannot handle recursive dependencies, so reference docs are written manually -.. class:: tamr_client.attributes.type.Map(inner_type) +.. class:: tamr_client.attribute.type.Map(inner_type) :param inner_type: :type inner_type: :class:`~tamr_client.AttributeType` -.. autoclass:: tamr_client.attributes.type.Record +.. autoclass:: tamr_client.attribute.type.Record -.. autofunction:: tamr_client.attributes.type.from_json -.. autofunction:: tamr_client.attributes.type.to_json +.. autofunction:: tamr_client.attribute.type.from_json +.. autofunction:: tamr_client.attribute.type.to_json diff --git a/docs/beta/attributes.md b/docs/beta/attributes.md deleted file mode 100644 index 008b1237..00000000 --- a/docs/beta/attributes.md +++ /dev/null @@ -1,5 +0,0 @@ -# Attributes - - * [Attribute](/beta/attributes/attribute) - * [Attribute Type](/beta/attributes/type) - * [SubAttribute](/beta/attributes/subattribute) diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 036275ba..a4afb30f 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -45,7 +45,7 @@ from tamr_client.dataset import dataframe # attributes -from tamr_client.attributes import attribute +from tamr_client import attribute from tamr_client import mastering from tamr_client import project diff --git a/tamr_client/attribute/__init__.py b/tamr_client/attribute/__init__.py new file mode 100644 index 00000000..6f53be4e --- /dev/null +++ b/tamr_client/attribute/__init__.py @@ -0,0 +1,14 @@ +from . import sub +from . import type +from ._attribute import ( + _from_json, + AlreadyExists, + create, + delete, + from_dataset_all, + from_resource_id, + NotFound, + ReservedName, + to_json, + update, +) diff --git a/tamr_client/attributes/attribute.py b/tamr_client/attribute/_attribute.py similarity index 94% rename from tamr_client/attributes/attribute.py rename to tamr_client/attribute/_attribute.py index 8e80f854..26fbc5ed 100644 --- a/tamr_client/attributes/attribute.py +++ b/tamr_client/attribute/_attribute.py @@ -7,7 +7,7 @@ from tamr_client import response from tamr_client._types import Attribute, AttributeType, JsonDict, URL -from tamr_client.attributes import type as attribute_type +from tamr_client.attribute import type as attribute_type from tamr_client.dataset.dataset import Dataset from tamr_client.session import Session @@ -31,16 +31,16 @@ ) -class NotFound(Exception): - """Raised when referencing (e.g. updating or deleting) an attribute - that does not exist on the server. - """ +class AlreadyExists(Exception): + """Raised when trying to create an attribute that already exists on the server""" pass -class AlreadyExists(Exception): - """Raised when trying to create an attribute that already exists on the server""" +class NotFound(Exception): + """Raised when referencing (e.g. updating or deleting) an attribute + that does not exist on the server. + """ pass @@ -61,7 +61,7 @@ def from_resource_id(session: Session, dataset: Dataset, id: str) -> Attribute: id: Attribute ID Raises: - attributes.NotFound: If no attribute could be found at the specified URL. + attribute.NotFound: If no attribute could be found at the specified URL. Corresponds to a 404 HTTP error. requests.HTTPError: If any other HTTP error is encountered. """ @@ -78,7 +78,7 @@ def _from_url(session: Session, url: URL) -> Attribute: url: Attribute URL Raises: - attributes.NotFound: If no attribute could be found at the specified URL. + attribute.NotFound: If no attribute could be found at the specified URL. Corresponds to a 404 HTTP error. requests.HTTPError: If any other HTTP error is encountered. """ @@ -239,7 +239,7 @@ def update( The newly updated attribute Raises: - attributes.NotFound: If no attribute could be found at the specified URL. + attribute.NotFound: If no attribute could be found at the specified URL. Corresponds to a 404 HTTP error. requests.HTTPError: If any other HTTP error is encountered. """ @@ -260,7 +260,7 @@ def delete(session: Session, attribute: Attribute): attribute: Existing attribute to delete Raises: - attributes.NotFound: If no attribute could be found at the specified URL. + attribute.NotFound: If no attribute could be found at the specified URL. Corresponds to a 404 HTTP error. requests.HTTPError: If any other HTTP error is encountered. """ diff --git a/tamr_client/attributes/sub.py b/tamr_client/attribute/sub.py similarity index 93% rename from tamr_client/attributes/sub.py rename to tamr_client/attribute/sub.py index 5f683b4b..63b91b5e 100644 --- a/tamr_client/attributes/sub.py +++ b/tamr_client/attribute/sub.py @@ -4,7 +4,7 @@ from copy import deepcopy from tamr_client._types import JsonDict, SubAttribute -from tamr_client.attributes import type as attribute_type +from tamr_client.attribute import type as attribute_type def from_json(data: JsonDict) -> SubAttribute: diff --git a/tamr_client/attributes/type.py b/tamr_client/attribute/type.py similarity index 98% rename from tamr_client/attributes/type.py rename to tamr_client/attribute/type.py index 6a7188b1..ff8b90ef 100644 --- a/tamr_client/attributes/type.py +++ b/tamr_client/attribute/type.py @@ -20,7 +20,7 @@ LONG, STRING, ) -from tamr_client.attributes import sub +from tamr_client.attribute import sub logger = logging.getLogger(__name__) diff --git a/tamr_client/attributes/__init__.py b/tamr_client/attributes/__init__.py deleted file mode 100644 index c4e38595..00000000 --- a/tamr_client/attributes/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from . import sub -from . import type -from .attribute import AlreadyExists, NotFound, ReservedName diff --git a/tests/tamr_client/attributes/test_attribute.py b/tests/tamr_client/attribute/test_attribute.py similarity index 90% rename from tests/tamr_client/attributes/test_attribute.py rename to tests/tamr_client/attribute/test_attribute.py index ed340d0d..3fffb0ea 100644 --- a/tests/tamr_client/attributes/test_attribute.py +++ b/tests/tamr_client/attribute/test_attribute.py @@ -40,7 +40,7 @@ def test_create(): tc.SubAttribute( name=str(i), is_nullable=True, - type=tc.attributes.type.Array(tc.attributes.type.STRING), + type=tc.attribute.type.Array(tc.attribute.type.STRING), ) for i in range(4) ] @@ -55,7 +55,7 @@ def test_create(): dataset, name="attr", is_nullable=False, - type=tc.attributes.type.Record(attributes=attrs), + type=tc.attribute.type.Record(attributes=attrs), ) assert attr == tc.attribute._from_json(url, attr_json) @@ -111,7 +111,7 @@ def test_from_resource_id_attribute_not_found(): url = replace(dataset.url, path=dataset.url.path + "/attributes/attr") responses.add(responses.GET, str(url), status=404) - with pytest.raises(tc.attributes.NotFound): + with pytest.raises(tc.attribute.NotFound): tc.attribute.from_resource_id(s, dataset, "attr") @@ -119,7 +119,7 @@ def test_create_reserved_attribute_name(): s = utils.session() dataset = utils.dataset() - with pytest.raises(tc.attributes.ReservedName): + with pytest.raises(tc.attribute.ReservedName): tc.attribute.create(s, dataset, name="clusterId", is_nullable=False) @@ -136,11 +136,11 @@ def test_from_dataset_all(): row_num = attrs[0] assert row_num.name == "RowNum" - assert row_num.type == tc.attributes.type.STRING + assert row_num.type == tc.attribute.type.STRING geom = attrs[1] assert geom.name == "geom" - assert isinstance(geom.type, tc.attributes.type.Record) + assert isinstance(geom.type, tc.attribute.type.Record) @responses.activate @@ -150,7 +150,7 @@ def test_create_attribute_exists(): url = replace(dataset.url, path=dataset.url.path + "/attributes") responses.add(responses.POST, str(url), status=409) - with pytest.raises(tc.attributes.AlreadyExists): + with pytest.raises(tc.attribute.AlreadyExists): tc.attribute.create(s, dataset, name="attr", is_nullable=False) @@ -163,7 +163,7 @@ def test_update_attribute_not_found(): attr = tc.attribute._from_json(url, attr_json) responses.add(responses.PUT, str(attr.url), status=404) - with pytest.raises(tc.attributes.NotFound): + with pytest.raises(tc.attribute.NotFound): tc.attribute.update(s, attr) @@ -176,5 +176,5 @@ def test_delete_attribute_not_found(): attr = tc.attribute._from_json(url, attr_json) responses.add(responses.PUT, str(attr.url), status=404) - with pytest.raises(tc.attributes.NotFound): + with pytest.raises(tc.attribute.NotFound): attr = tc.attribute.update(s, attr) diff --git a/tests/tamr_client/attributes/test_type.py b/tests/tamr_client/attribute/test_type.py similarity index 56% rename from tests/tamr_client/attributes/test_type.py rename to tests/tamr_client/attribute/test_type.py index 25d26915..3b98bc57 100644 --- a/tests/tamr_client/attributes/test_type.py +++ b/tests/tamr_client/attribute/test_type.py @@ -4,28 +4,28 @@ def test_from_json(): geom_json = utils.load_json("attributes.json")[1] - geom_type = tc.attributes.type.from_json(geom_json["type"]) - assert isinstance(geom_type, tc.attributes.type.Record) + geom_type = tc.attribute.type.from_json(geom_json["type"]) + assert isinstance(geom_type, tc.attribute.type.Record) for i, subattr in enumerate(geom_type.attributes): assert isinstance(subattr, tc.SubAttribute) if i == 0: assert subattr.name == "point" - assert subattr.type == tc.attributes.type.Array(tc.attributes.type.DOUBLE) + assert subattr.type == tc.attribute.type.Array(tc.attribute.type.DOUBLE) assert subattr.is_nullable assert subattr.description is None elif i == 1: assert subattr.name == "lineString" - assert subattr.type == tc.attributes.type.Array( - tc.attributes.type.Array(tc.attributes.type.DOUBLE) + assert subattr.type == tc.attribute.type.Array( + tc.attribute.type.Array(tc.attribute.type.DOUBLE) ) assert subattr.is_nullable assert subattr.description is None elif i == 2: assert subattr.name == "polygon" - assert subattr.type == tc.attributes.type.Array( - tc.attributes.type.Array( - tc.attributes.type.Array(tc.attributes.type.DOUBLE) + assert subattr.type == tc.attribute.type.Array( + tc.attribute.type.Array( + tc.attribute.type.Array(tc.attribute.type.DOUBLE) ) ) assert subattr.is_nullable @@ -36,7 +36,7 @@ def test_json(): attrs_json = utils.load_json("attributes.json") for attr_json in attrs_json: attr_type_json = attr_json["type"] - attr_type = tc.attributes.type.from_json(attr_type_json) - assert attr_type == tc.attributes.type.from_json( - tc.attributes.type.to_json(attr_type) + attr_type = tc.attribute.type.from_json(attr_type_json) + assert attr_type == tc.attribute.type.from_json( + tc.attribute.type.to_json(attr_type) ) From fef73a099ec1554e4f9cedb2e5861b6c3053cd10 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 29 Jun 2020 13:27:23 -0400 Subject: [PATCH 455/632] Demote pandas to a dev dependency --- docs/requirements.txt | 1 + noxfile.py | 2 +- poetry.lock | 97 +++++++++++++++++--------------- pyproject.toml | 2 +- tamr_client/dataset/dataframe.py | 11 ++-- 5 files changed, 61 insertions(+), 52 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index fbb0b9b9..bbb50dcc 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,5 @@ # TODO(pcattori) Delete this file once RTD fully supports poetry +pandas==1.0.5 recommonmark==0.6.0 sphinx_rtd_theme==0.4.3 sphinx-autodoc-typehints==1.8.0 diff --git a/noxfile.py b/noxfile.py index d2f43b21..bb9c28e5 100644 --- a/noxfile.py +++ b/noxfile.py @@ -56,5 +56,5 @@ def docs(session): "docs", "docs/_build", "-W", - env={"TAMR_CLIENT_BETA": "1"}, + env={"TAMR_CLIENT_BETA": "1", "TAMR_CLIENT_DOCS": "1"}, ) diff --git a/poetry.lock b/poetry.lock index be1aa977..2a247f0a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -344,12 +344,12 @@ version = "*" tox_to_nox = ["jinja2", "tox"] [[package]] -category = "main" +category = "dev" description = "NumPy is the fundamental package for array computing with Python." name = "numpy" optional = false -python-versions = ">=3.5" -version = "1.18.2" +python-versions = ">=3.6" +version = "1.19.0" [[package]] category = "dev" @@ -364,12 +364,12 @@ pyparsing = ">=2.0.2" six = "*" [[package]] -category = "main" +category = "dev" description = "Powerful data structures for data analysis, time series, and statistics" name = "pandas" optional = false python-versions = ">=3.6.1" -version = "1.0.3" +version = "1.0.5" [package.dependencies] numpy = ">=1.13.3" @@ -462,7 +462,7 @@ checkqa-mypy = ["mypy (v0.761)"] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] [[package]] -category = "main" +category = "dev" description = "Extensions to the standard Python datetime module" name = "python-dateutil" optional = false @@ -473,7 +473,7 @@ version = "2.8.1" six = ">=1.5" [[package]] -category = "main" +category = "dev" description = "World timezone definitions, modern and historical" name = "pytz" optional = false @@ -535,7 +535,7 @@ python-versions = ">=2.5, !=3.0.*, !=3.1.*, !=3.2.*" version = "3.16.0" [[package]] -category = "main" +category = "dev" description = "Python 2 and 3 compatibility utilities" name = "six" optional = false @@ -761,7 +761,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["pathlib2", "contextlib2", "unittest2"] [metadata] -content-hash = "fbae3ed35d6137484f2633797b45e736fa08a758fe3509e5bab3564ec6e85d2a" +content-hash = "06a749e5e2a951e6e63cb2532b8c31200f9c7dc3767726b18f19ed0c8858c0f4" python-versions = "^3.6.1" [metadata.files] @@ -927,49 +927,54 @@ nox = [ {file = "nox-2020.5.24.tar.gz", hash = "sha256:61a55705736a1a73efbd18d5b262a43d55a1176546e0eb28b29064cfcffe26c0"}, ] numpy = [ - {file = "numpy-1.18.2-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a1baa1dc8ecd88fb2d2a651671a84b9938461e8a8eed13e2f0a812a94084d1fa"}, - {file = "numpy-1.18.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a244f7af80dacf21054386539699ce29bcc64796ed9850c99a34b41305630286"}, - {file = "numpy-1.18.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:6fcc5a3990e269f86d388f165a089259893851437b904f422d301cdce4ff25c8"}, - {file = "numpy-1.18.2-cp35-cp35m-win32.whl", hash = "sha256:b5ad0adb51b2dee7d0ee75a69e9871e2ddfb061c73ea8bc439376298141f77f5"}, - {file = "numpy-1.18.2-cp35-cp35m-win_amd64.whl", hash = "sha256:87902e5c03355335fc5992a74ba0247a70d937f326d852fc613b7f53516c0963"}, - {file = "numpy-1.18.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9ab21d1cb156a620d3999dd92f7d1c86824c622873841d6b080ca5495fa10fef"}, - {file = "numpy-1.18.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:cdb3a70285e8220875e4d2bc394e49b4988bdb1298ffa4e0bd81b2f613be397c"}, - {file = "numpy-1.18.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:6d205249a0293e62bbb3898c4c2e1ff8a22f98375a34775a259a0523111a8f6c"}, - {file = "numpy-1.18.2-cp36-cp36m-win32.whl", hash = "sha256:a35af656a7ba1d3decdd4fae5322b87277de8ac98b7d9da657d9e212ece76a61"}, - {file = "numpy-1.18.2-cp36-cp36m-win_amd64.whl", hash = "sha256:1598a6de323508cfeed6b7cd6c4efb43324f4692e20d1f76e1feec7f59013448"}, - {file = "numpy-1.18.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:deb529c40c3f1e38d53d5ae6cd077c21f1d49e13afc7936f7f868455e16b64a0"}, - {file = "numpy-1.18.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:cd77d58fb2acf57c1d1ee2835567cd70e6f1835e32090538f17f8a3a99e5e34b"}, - {file = "numpy-1.18.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:b1fe1a6f3a6f355f6c29789b5927f8bd4f134a4bd9a781099a7c4f66af8850f5"}, - {file = "numpy-1.18.2-cp37-cp37m-win32.whl", hash = "sha256:2e40be731ad618cb4974d5ba60d373cdf4f1b8dcbf1dcf4d9dff5e212baf69c5"}, - {file = "numpy-1.18.2-cp37-cp37m-win_amd64.whl", hash = "sha256:4ba59db1fcc27ea31368af524dcf874d9277f21fd2e1f7f1e2e0c75ee61419ed"}, - {file = "numpy-1.18.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:59ca9c6592da581a03d42cc4e270732552243dc45e87248aa8d636d53812f6a5"}, - {file = "numpy-1.18.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1b0ece94018ae21163d1f651b527156e1f03943b986188dd81bc7e066eae9d1c"}, - {file = "numpy-1.18.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:82847f2765835c8e5308f136bc34018d09b49037ec23ecc42b246424c767056b"}, - {file = "numpy-1.18.2-cp38-cp38-win32.whl", hash = "sha256:5e0feb76849ca3e83dd396254e47c7dba65b3fa9ed3df67c2556293ae3e16de3"}, - {file = "numpy-1.18.2-cp38-cp38-win_amd64.whl", hash = "sha256:ba3c7a2814ec8a176bb71f91478293d633c08582119e713a0c5351c0f77698da"}, - {file = "numpy-1.18.2.zip", hash = "sha256:e7894793e6e8540dbeac77c87b489e331947813511108ae097f1715c018b8f3d"}, + {file = "numpy-1.19.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:63d971bb211ad3ca37b2adecdd5365f40f3b741a455beecba70fd0dde8b2a4cb"}, + {file = "numpy-1.19.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:b6aaeadf1e4866ca0fdf7bb4eed25e521ae21a7947c59f78154b24fc7abbe1dd"}, + {file = "numpy-1.19.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:13af0184177469192d80db9bd02619f6fa8b922f9f327e077d6f2a6acb1ce1c0"}, + {file = "numpy-1.19.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:356f96c9fbec59974a592452ab6a036cd6f180822a60b529a975c9467fcd5f23"}, + {file = "numpy-1.19.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa1fe75b4a9e18b66ae7f0b122543c42debcf800aaafa0212aaff3ad273c2596"}, + {file = "numpy-1.19.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:cbe326f6d364375a8e5a8ccb7e9cd73f4b2f6dc3b2ed205633a0db8243e2a96a"}, + {file = "numpy-1.19.0-cp36-cp36m-win32.whl", hash = "sha256:a2e3a39f43f0ce95204beb8fe0831199542ccab1e0c6e486a0b4947256215632"}, + {file = "numpy-1.19.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7b852817800eb02e109ae4a9cef2beda8dd50d98b76b6cfb7b5c0099d27b52d4"}, + {file = "numpy-1.19.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d97a86937cf9970453c3b62abb55a6475f173347b4cde7f8dcdb48c8e1b9952d"}, + {file = "numpy-1.19.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:a86c962e211f37edd61d6e11bb4df7eddc4a519a38a856e20a6498c319efa6b0"}, + {file = "numpy-1.19.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d34fbb98ad0d6b563b95de852a284074514331e6b9da0a9fc894fb1cdae7a79e"}, + {file = "numpy-1.19.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:658624a11f6e1c252b2cd170d94bf28c8f9410acab9f2fd4369e11e1cd4e1aaf"}, + {file = "numpy-1.19.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:4d054f013a1983551254e2379385e359884e5af105e3efe00418977d02f634a7"}, + {file = "numpy-1.19.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:26a45798ca2a4e168d00de75d4a524abf5907949231512f372b217ede3429e98"}, + {file = "numpy-1.19.0-cp37-cp37m-win32.whl", hash = "sha256:3c40c827d36c6d1c3cf413694d7dc843d50997ebffbc7c87d888a203ed6403a7"}, + {file = "numpy-1.19.0-cp37-cp37m-win_amd64.whl", hash = "sha256:be62aeff8f2f054eff7725f502f6228298891fd648dc2630e03e44bf63e8cee0"}, + {file = "numpy-1.19.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:dd53d7c4a69e766e4900f29db5872f5824a06827d594427cf1a4aa542818b796"}, + {file = "numpy-1.19.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:30a59fb41bb6b8c465ab50d60a1b298d1cd7b85274e71f38af5a75d6c475d2d2"}, + {file = "numpy-1.19.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:df1889701e2dfd8ba4dc9b1a010f0a60950077fb5242bb92c8b5c7f1a6f2668a"}, + {file = "numpy-1.19.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:33c623ef9ca5e19e05991f127c1be5aeb1ab5cdf30cb1c5cf3960752e58b599b"}, + {file = "numpy-1.19.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:26f509450db547e4dfa3ec739419b31edad646d21fb8d0ed0734188b35ff6b27"}, + {file = "numpy-1.19.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:7b57f26e5e6ee2f14f960db46bd58ffdca25ca06dd997729b1b179fddd35f5a3"}, + {file = "numpy-1.19.0-cp38-cp38-win32.whl", hash = "sha256:a8705c5073fe3fcc297fb8e0b31aa794e05af6a329e81b7ca4ffecab7f2b95ef"}, + {file = "numpy-1.19.0-cp38-cp38-win_amd64.whl", hash = "sha256:c2edbb783c841e36ca0fa159f0ae97a88ce8137fb3a6cd82eae77349ba4b607b"}, + {file = "numpy-1.19.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:8cde829f14bd38f6da7b2954be0f2837043e8b8d7a9110ec5e318ae6bf706610"}, + {file = "numpy-1.19.0.zip", hash = "sha256:76766cc80d6128750075378d3bb7812cf146415bd29b588616f72c943c00d598"}, ] packaging = [ {file = "packaging-19.0-py2.py3-none-any.whl", hash = "sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3"}, {file = "packaging-19.0.tar.gz", hash = "sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af"}, ] pandas = [ - {file = "pandas-1.0.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d234bcf669e8b4d6cbcd99e3ce7a8918414520aeb113e2a81aeb02d0a533d7f7"}, - {file = "pandas-1.0.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:ca84a44cf727f211752e91eab2d1c6c1ab0f0540d5636a8382a3af428542826e"}, - {file = "pandas-1.0.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:1fa4bae1a6784aa550a1c9e168422798104a85bf9c77a1063ea77ee6f8452e3a"}, - {file = "pandas-1.0.3-cp36-cp36m-win32.whl", hash = "sha256:863c3e4b7ae550749a0bb77fa22e601a36df9d2905afef34a6965bed092ba9e5"}, - {file = "pandas-1.0.3-cp36-cp36m-win_amd64.whl", hash = "sha256:a210c91a02ec5ff05617a298ad6f137b9f6f5771bf31f2d6b6367d7f71486639"}, - {file = "pandas-1.0.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:11c7cb654cd3a0e9c54d81761b5920cdc86b373510d829461d8f2ed6d5905266"}, - {file = "pandas-1.0.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6597df07ea361231e60c00692d8a8099b519ed741c04e65821e632bc9ccb924c"}, - {file = "pandas-1.0.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:743bba36e99d4440403beb45a6f4f3a667c090c00394c176092b0b910666189b"}, - {file = "pandas-1.0.3-cp37-cp37m-win32.whl", hash = "sha256:07c1b58936b80eafdfe694ce964ac21567b80a48d972879a359b3ebb2ea76835"}, - {file = "pandas-1.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:12f492dd840e9db1688126216706aa2d1fcd3f4df68a195f9479272d50054645"}, - {file = "pandas-1.0.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0ebe327fb088df4d06145227a4aa0998e4f80a9e6aed4b61c1f303bdfdf7c722"}, - {file = "pandas-1.0.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:858a0d890d957ae62338624e4aeaf1de436dba2c2c0772570a686eaca8b4fc85"}, - {file = "pandas-1.0.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:387dc7b3c0424327fe3218f81e05fc27832772a5dffbed385013161be58df90b"}, - {file = "pandas-1.0.3-cp38-cp38-win32.whl", hash = "sha256:167a1315367cea6ec6a5e11e791d9604f8e03f95b57ad227409de35cf850c9c5"}, - {file = "pandas-1.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:1a7c56f1df8d5ad8571fa251b864231f26b47b59cbe41aa5c0983d17dbb7a8e4"}, - {file = "pandas-1.0.3.tar.gz", hash = "sha256:32f42e322fb903d0e189a4c10b75ba70d90958cc4f66a1781ed027f1a1d14586"}, + {file = "pandas-1.0.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:faa42a78d1350b02a7d2f0dbe3c80791cf785663d6997891549d0f86dc49125e"}, + {file = "pandas-1.0.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:9c31d52f1a7dd2bb4681d9f62646c7aa554f19e8e9addc17e8b1b20011d7522d"}, + {file = "pandas-1.0.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8778a5cc5a8437a561e3276b85367412e10ae9fff07db1eed986e427d9a674f8"}, + {file = "pandas-1.0.5-cp36-cp36m-win32.whl", hash = "sha256:9871ef5ee17f388f1cb35f76dc6106d40cb8165c562d573470672f4cdefa59ef"}, + {file = "pandas-1.0.5-cp36-cp36m-win_amd64.whl", hash = "sha256:35b670b0abcfed7cad76f2834041dcf7ae47fd9b22b63622d67cdc933d79f453"}, + {file = "pandas-1.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c9410ce8a3dee77653bc0684cfa1535a7f9c291663bd7ad79e39f5ab58f67ab3"}, + {file = "pandas-1.0.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:02f1e8f71cd994ed7fcb9a35b6ddddeb4314822a0e09a9c5b2d278f8cb5d4096"}, + {file = "pandas-1.0.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:b3c4f93fcb6e97d993bf87cdd917883b7dab7d20c627699f360a8fb49e9e0b91"}, + {file = "pandas-1.0.5-cp37-cp37m-win32.whl", hash = "sha256:5759edf0b686b6f25a5d4a447ea588983a33afc8a0081a0954184a4a87fd0dd7"}, + {file = "pandas-1.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:ab8173a8efe5418bbe50e43f321994ac6673afc5c7c4839014cf6401bbdd0705"}, + {file = "pandas-1.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:13f75fb18486759da3ff40f5345d9dd20e7d78f2a39c5884d013456cec9876f0"}, + {file = "pandas-1.0.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5a7cf6044467c1356b2b49ef69e50bf4d231e773c3ca0558807cdba56b76820b"}, + {file = "pandas-1.0.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:ae961f1f0e270f1e4e2273f6a539b2ea33248e0e3a11ffb479d757918a5e03a9"}, + {file = "pandas-1.0.5-cp38-cp38-win32.whl", hash = "sha256:f69e0f7b7c09f1f612b1f8f59e2df72faa8a6b41c5a436dde5b615aaf948f107"}, + {file = "pandas-1.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:4c73f373b0800eb3062ffd13d4a7a2a6d522792fa6eb204d67a4fad0a40f03dc"}, + {file = "pandas-1.0.5.tar.gz", hash = "sha256:69c5d920a0b2a9838e677f78f4dde506b95ea8e4d30da25859db6469ded84fa8"}, ] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, diff --git a/pyproject.toml b/pyproject.toml index 31472e97..06b86a25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,6 @@ python = "^3.6.1" requests = "^2.22" simplejson = "^3.16" dataclasses = "^0.6.0" -pandas = "^1.0.3" [tool.poetry.dev-dependencies] Sphinx = "^2.1" @@ -46,6 +45,7 @@ sphinx-autodoc-typehints = "^1.8" pytest = "^5.3.2" mypy = "^0.770" nox = "^2020.5.24" +pandas = "^1.0.5" [build-system] requires = ["poetry>=1.0"] diff --git a/tamr_client/dataset/dataframe.py b/tamr_client/dataset/dataframe.py index 1bd5f9b1..909c4586 100644 --- a/tamr_client/dataset/dataframe.py +++ b/tamr_client/dataset/dataframe.py @@ -3,15 +3,18 @@ """ import json -from typing import Optional - -import pandas as pd +import os +from typing import Optional, TYPE_CHECKING from tamr_client._types import JsonDict from tamr_client.dataset import record from tamr_client.dataset.dataset import Dataset from tamr_client.session import Session +BUILDING_DOCS = os.environ.get("TAMR_CLIENT_DOCS") == "1" +if TYPE_CHECKING or BUILDING_DOCS: + import pandas as pd + class AmbiguousPrimaryKey(Exception): """Raised when referencing a primary key by name that matches multiple possible targets.""" @@ -22,7 +25,7 @@ class AmbiguousPrimaryKey(Exception): def upsert( session: Session, dataset: Dataset, - df: pd.DataFrame, + df: "pd.DataFrame", *, primary_key_name: Optional[str] = None, ) -> JsonDict: From 54303a4bc6f9bf4315972606575979a6b539a017 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 30 Jun 2020 08:04:37 -0400 Subject: [PATCH 456/632] Convert relative imports to absolute imports --- tamr_client/__init__.py | 2 +- tamr_client/_types/__init__.py | 6 +++--- tamr_client/attribute/__init__.py | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index a4afb30f..9da8ef0c 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -13,7 +13,7 @@ # https://docs.python-guide.org/writing/logging/#logging-in-a-library logging.getLogger(__name__).addHandler(logging.NullHandler()) -from ._types import Attribute, AttributeType, SubAttribute, URL +from tamr_client._types import Attribute, AttributeType, SubAttribute, URL # Import shortcuts ################## diff --git a/tamr_client/_types/__init__.py b/tamr_client/_types/__init__.py index a572eec9..26018dec 100644 --- a/tamr_client/_types/__init__.py +++ b/tamr_client/_types/__init__.py @@ -1,4 +1,4 @@ -from .attribute import ( +from tamr_client._types.attribute import ( Array, Attribute, AttributeType, @@ -15,5 +15,5 @@ STRING, SubAttribute, ) -from .json import JsonDict -from .url import URL +from tamr_client._types.json import JsonDict +from tamr_client._types.url import URL diff --git a/tamr_client/attribute/__init__.py b/tamr_client/attribute/__init__.py index 6f53be4e..847b6fbb 100644 --- a/tamr_client/attribute/__init__.py +++ b/tamr_client/attribute/__init__.py @@ -1,6 +1,6 @@ -from . import sub -from . import type -from ._attribute import ( +from tamr_client.attribute import sub +from tamr_client.attribute import type +from tamr_client.attribute._attribute import ( _from_json, AlreadyExists, create, From 8406a2ba511c961107ddba48a5332168e1d6f7ea Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 30 Jun 2020 08:04:59 -0400 Subject: [PATCH 457/632] Fix autodoc defaults deprecation warning --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 4b577c64..bc440042 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -54,7 +54,7 @@ "sphinx.ext.viewcode", "sphinx.ext.autosectionlabel", ] -autodoc_default_flags = ["inherited-members", "members"] +autodoc_default_options = {"inherited-members": True, "members": True} autodoc_member_order = "bysource" autosectionlabel_prefix_document = True intersphinx_mapping = { From 0064a0673d757410a7ba7be89ef026e4e7cd4162 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 30 Jun 2020 08:08:03 -0400 Subject: [PATCH 458/632] Explain why the `_types` package is necessary --- docs/contributor-guide/style-guide.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/contributor-guide/style-guide.md b/docs/contributor-guide/style-guide.md index b2df10eb..883ef9e8 100644 --- a/docs/contributor-guide/style-guide.md +++ b/docs/contributor-guide/style-guide.md @@ -8,6 +8,7 @@ Code should generally conform to the [PEP8 style guidelines](https://www.python. ### Structure * Classes with methods should be avoided in favor of simple [dataclasses](https://docs.python.org/3/library/dataclasses.html) and functions +* Types (i.e. `dataclass`es) should be within the `_types` package. Separating types and functions into different packages helps keep type resolution simple so that all of our tools (`mypy`, `sphinx`, `pytest`) run correctly. ### Google-style docstrings All functions and class definitions should use [Google-style docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) and be annotated with [type hints](https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html#type-annotations). From a291f9975b8397b38bdc37afaa5514d5595b88b8 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 1 Jul 2020 15:05:05 -0400 Subject: [PATCH 459/632] Hide beta module from users --- tamr_client/__init__.py | 11 +++++++---- tamr_client/{beta.py => _beta.py} | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) rename tamr_client/{beta.py => _beta.py} (97%) diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 9da8ef0c..6774bac2 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -1,9 +1,9 @@ # BETA check ############ -import tamr_client.beta as beta +from tamr_client import _beta -beta._check() +_beta.check() # Logging ######### @@ -13,10 +13,13 @@ # https://docs.python-guide.org/writing/logging/#logging-in-a-library logging.getLogger(__name__).addHandler(logging.NullHandler()) +# types +####### + from tamr_client._types import Attribute, AttributeType, SubAttribute, URL -# Import shortcuts -################## +# functions +########### # utilities from tamr_client import response diff --git a/tamr_client/beta.py b/tamr_client/_beta.py similarity index 97% rename from tamr_client/beta.py rename to tamr_client/_beta.py index cc04fcea..89f18bb6 100644 --- a/tamr_client/beta.py +++ b/tamr_client/_beta.py @@ -2,7 +2,7 @@ import sys -def _check(): +def check(): beta_flag = "TAMR_CLIENT_BETA" beta_enabled = "1" beta = os.environ.get(beta_flag) From aa55c092d5f8f21936e5f9287b41076e4dcfe1ac Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 1 Jul 2020 15:53:26 -0400 Subject: [PATCH 460/632] Simplified beta check Also, improved clarity of the beta error message --- tamr_client/_beta.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/tamr_client/_beta.py b/tamr_client/_beta.py index 89f18bb6..eaaed8ee 100644 --- a/tamr_client/_beta.py +++ b/tamr_client/_beta.py @@ -3,15 +3,14 @@ def check(): - beta_flag = "TAMR_CLIENT_BETA" - beta_enabled = "1" - beta = os.environ.get(beta_flag) + env_var = "TAMR_CLIENT_BETA" + is_beta_enabled = os.environ.get(env_var) == "1" - if beta != beta_enabled: + if not is_beta_enabled: msg = ( - f"ERROR: 'tamr_client' package is in BETA, but you do not have the '{beta_flag}' environment variable set to '1'." - "\n\nHINT: Use 'tamr_unify_client' package instead for non-BETA features" - f"\nHINT: Set environment variable '{beta_flag}=1' to opt-in to BETA features." + f"ERROR: 'tamr_client' package is in BETA, but you do not have the '{env_var}' environment variable set to '1'." + "\n\nHINT: For non-BETA features, use only the 'tamr_unify_client' package." + f"\nHINT: To opt-in to BETA features, set environment variable: '{env_var}=1'." "\n\nWARNING: Do not rely on BETA features in production workflows." " Support from Tamr may be limited." ) From a1910b807d6d7122bd86dfb5199174b0dcebcfbb Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 2 Jul 2020 09:48:18 -0400 Subject: [PATCH 461/632] Ignore F401 flake8 error explicitly __init__.py files are used to control the interface exposed to the user and do not contain any logic. For now tamr_client/__init__.py also ignores other errors, but in the future this file should also only ignore F401 errors. --- .flake8 | 4 +--- tamr_client/dataset/__init__.py | 3 +-- tamr_client/mastering/__init__.py | 3 +-- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.flake8 b/.flake8 index fefae2b8..06123d29 100644 --- a/.flake8 +++ b/.flake8 @@ -7,11 +7,9 @@ max-line-length = 88 max-complexity = 18 select = B,C,E,F,I,W,T4,B9 exclude = build,.venv,*.egg-info - per-file-ignores = tamr_client/__init__.py:E402,F401,I100,I202 - tamr_client/_types/__init__.py:F401 - tamr_client/attribute/__init__.py:F401 + tamr_client/*/__init__.py:F401 # flake8-import-order plugin import-order-style = google diff --git a/tamr_client/dataset/__init__.py b/tamr_client/dataset/__init__.py index f12f0821..e873d51a 100644 --- a/tamr_client/dataset/__init__.py +++ b/tamr_client/dataset/__init__.py @@ -1,5 +1,4 @@ -# flake8: noqa +from tamr_client.dataset import dataframe, record, unified from tamr_client.dataset.dataset import AnyDataset, Dataset from tamr_client.dataset.dataset import from_resource_id from tamr_client.dataset.dataset import NotFound -from tamr_client.dataset import dataframe, record, unified diff --git a/tamr_client/mastering/__init__.py b/tamr_client/mastering/__init__.py index c52d9c06..be36bf14 100644 --- a/tamr_client/mastering/__init__.py +++ b/tamr_client/mastering/__init__.py @@ -1,7 +1,6 @@ -# flake8: noqa """ Tamr - Mastering See https://docs.tamr.com/docs/overall-workflow-mastering """ -from tamr_client.mastering.project import Project import tamr_client.mastering.project +from tamr_client.mastering.project import Project From a4658c0e5eda2e1fe038e50dd17035053140ed38 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 2 Jul 2020 10:39:56 -0400 Subject: [PATCH 462/632] Stop tracking docs dependencies in two places Docs dependencies should be tracked in docs/requirements.txt for interop w/ RTD. The only dev task (aka nox session) that uses these dependencies is `docs`, so we can safely remove doc-only deps from pyproject.toml/poetry.lock . --- poetry.lock | 346 +------------------------------------------------ pyproject.toml | 5 - 2 files changed, 4 insertions(+), 347 deletions(-) diff --git a/poetry.lock b/poetry.lock index 2a247f0a..ff91eace 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,11 +1,3 @@ -[[package]] -category = "dev" -description = "A configurable sidebar-enabled Sphinx theme" -name = "alabaster" -optional = false -python-versions = "*" -version = "0.7.12" - [[package]] category = "dev" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." @@ -52,17 +44,6 @@ dev = ["coverage", "hypothesis", "pympler", "pytest", "six", "zope.interface", " docs = ["sphinx", "zope.interface"] tests = ["coverage", "hypothesis", "pympler", "pytest", "six", "zope.interface"] -[[package]] -category = "dev" -description = "Internationalization utilities" -name = "babel" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.7.0" - -[package.dependencies] -pytz = ">=2015.7" - [[package]] category = "dev" description = "The uncompromising code formatter." @@ -124,20 +105,6 @@ version = "4.1.0" [package.dependencies] colorama = "*" -[[package]] -category = "dev" -description = "Python parser for the CommonMark Markdown spec" -name = "commonmark" -optional = false -python-versions = "*" -version = "0.9.0" - -[package.dependencies] -future = "*" - -[package.extras] -test = ["flake8 (3.5.0)", "hypothesis (3.55.3)"] - [[package]] category = "main" description = "A backport of the dataclasses module for Python 3.6" @@ -154,14 +121,6 @@ optional = false python-versions = "*" version = "0.3.0" -[[package]] -category = "dev" -description = "Docutils -- Python Documentation Utilities" -name = "docutils" -optional = false -python-versions = "*" -version = "0.14" - [[package]] category = "dev" description = "A platform independent file lock." @@ -199,14 +158,6 @@ version = "0.18.1" pycodestyle = "*" setuptools = "*" -[[package]] -category = "dev" -description = "Clean single-source support for Python 3 and 2" -name = "future" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -version = "0.17.1" - [[package]] category = "main" description = "Internationalized Domain Names in Applications (IDNA)" @@ -215,14 +166,6 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "2.8" -[[package]] -category = "dev" -description = "Getting image size from png/jpeg/jpeg2000/gif file" -name = "imagesize" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.1.0" - [[package]] category = "dev" description = "Read metadata from Python packages" @@ -260,28 +203,6 @@ version = ">=0.4" [package.extras] docs = ["sphinx", "rst.linker", "jaraco.packaging"] -[[package]] -category = "dev" -description = "A small but fast and easy to use stand-alone template engine written in pure python." -name = "jinja2" -optional = false -python-versions = "*" -version = "2.10.1" - -[package.dependencies] -MarkupSafe = ">=0.23" - -[package.extras] -i18n = ["Babel (>=0.8)"] - -[[package]] -category = "dev" -description = "Safely add untrusted strings to HTML/XML markup." -name = "markupsafe" -optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" -version = "1.1.1" - [[package]] category = "dev" description = "McCabe checker, plugin for flake8" @@ -419,14 +340,6 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "2.2.0" -[[package]] -category = "dev" -description = "Pygments is a syntax highlighting package written in Python." -name = "pygments" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.4.2" - [[package]] category = "dev" description = "Python parsing module" @@ -480,19 +393,6 @@ optional = false python-versions = "*" version = "2019.1" -[[package]] -category = "dev" -description = "A docutils-compatibility bridge to CommonMark, enabling you to write CommonMark inside of Docutils & Sphinx projects." -name = "recommonmark" -optional = false -python-versions = "*" -version = "0.6.0" - -[package.dependencies] -commonmark = ">=0.8.1" -docutils = ">=0.11" -sphinx = ">=1.3.1" - [[package]] category = "main" description = "Python HTTP for Humans." @@ -542,144 +442,13 @@ optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*" version = "1.12.0" -[[package]] -category = "dev" -description = "This package provides 16 stemmer algorithms (15 + Poerter English stemmer) generated from Snowball algorithms." -name = "snowballstemmer" -optional = false -python-versions = "*" -version = "1.2.1" - -[[package]] -category = "dev" -description = "Python documentation generator" -name = "sphinx" -optional = false -python-versions = ">=3.5" -version = "2.1.0" - -[package.dependencies] -Jinja2 = ">=2.3" -Pygments = ">=2.0" -alabaster = ">=0.7,<0.8" -babel = ">=1.3,<2.0 || >2.0" -colorama = ">=0.3.5" -docutils = ">=0.12" -imagesize = "*" -packaging = "*" -requests = ">=2.5.0" -setuptools = "*" -snowballstemmer = ">=1.1" -sphinxcontrib-applehelp = "*" -sphinxcontrib-devhelp = "*" -sphinxcontrib-htmlhelp = "*" -sphinxcontrib-jsmath = "*" -sphinxcontrib-qthelp = "*" -sphinxcontrib-serializinghtml = "*" - -[package.extras] -docs = ["sphinxcontrib-websupport"] -test = ["pytest", "pytest-cov", "html5lib", "flake8 (>=3.5.0)", "flake8-import-order", "mypy (>=0.590)", "docutils-stubs"] - -[[package]] -category = "dev" -description = "Type hints (PEP 484) support for the Sphinx autodoc extension" -name = "sphinx-autodoc-typehints" -optional = false -python-versions = ">=3.5.2" -version = "1.8.0" - -[package.dependencies] -Sphinx = ">=2.1" - -[package.extras] -test = ["pytest (>=3.1.0)", "typing-extensions (>=3.5)", "dataclasses"] -type_comments = ["typed-ast (>=1.4.0)"] - -[[package]] -category = "dev" -description = "Read the Docs theme for Sphinx" -name = "sphinx-rtd-theme" -optional = false -python-versions = "*" -version = "0.4.3" - -[package.dependencies] -sphinx = "*" - -[[package]] -category = "dev" -description = "" -name = "sphinxcontrib-applehelp" -optional = false -python-versions = "*" -version = "1.0.1" - -[package.extras] -test = ["pytest", "flake8", "mypy"] - -[[package]] -category = "dev" -description = "" -name = "sphinxcontrib-devhelp" -optional = false -python-versions = "*" -version = "1.0.1" - -[package.extras] -test = ["pytest", "flake8", "mypy"] - -[[package]] -category = "dev" -description = "" -name = "sphinxcontrib-htmlhelp" -optional = false -python-versions = "*" -version = "1.0.2" - -[package.extras] -test = ["pytest", "flake8", "mypy", "html5lib"] - -[[package]] -category = "dev" -description = "A sphinx extension which renders display math in HTML via JavaScript" -name = "sphinxcontrib-jsmath" -optional = false -python-versions = ">=3.5" -version = "1.0.1" - -[package.extras] -test = ["pytest", "flake8", "mypy"] - -[[package]] -category = "dev" -description = "" -name = "sphinxcontrib-qthelp" -optional = false -python-versions = "*" -version = "1.0.2" - -[package.extras] -test = ["pytest", "flake8", "mypy"] - -[[package]] -category = "dev" -description = "" -name = "sphinxcontrib-serializinghtml" -optional = false -python-versions = "*" -version = "1.1.3" - -[package.extras] -test = ["pytest", "flake8", "mypy"] - [[package]] category = "dev" description = "Python Library for Tom's Obvious, Minimal Language" name = "toml" optional = false python-versions = "*" -version = "0.10.0" +version = "0.10.1" [[package]] category = "dev" @@ -761,14 +530,10 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["pathlib2", "contextlib2", "unittest2"] [metadata] -content-hash = "06a749e5e2a951e6e63cb2532b8c31200f9c7dc3767726b18f19ed0c8858c0f4" +content-hash = "40af657270d107cadba1da7a88b9702582fcd2eb4fbddadb8e2788a8b395f668" python-versions = "^3.6.1" [metadata.files] -alabaster = [ - {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, - {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, -] appdirs = [ {file = "appdirs-1.4.3-py2.py3-none-any.whl", hash = "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"}, {file = "appdirs-1.4.3.tar.gz", hash = "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92"}, @@ -785,10 +550,6 @@ attrs = [ {file = "attrs-19.1.0-py2.py3-none-any.whl", hash = "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79"}, {file = "attrs-19.1.0.tar.gz", hash = "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399"}, ] -babel = [ - {file = "Babel-2.7.0-py2.py3-none-any.whl", hash = "sha256:af92e6106cb7c55286b25b38ad7695f8b4efb36a90ba483d7f7a6628c46158ab"}, - {file = "Babel-2.7.0.tar.gz", hash = "sha256:e86135ae101e31e2c8ec20a4e0c5220f4eed12487d5cf3f78be7e98d3a57fc28"}, -] black = [ {file = "black-19.3b0-py36-none-any.whl", hash = "sha256:09a9dcb7c46ed496a9850b76e4e825d6049ecd38b611f1224857a79bd985a8cf"}, {file = "black-19.3b0.tar.gz", hash = "sha256:68950ffd4d9169716bcb8719a56c07a2f4485354fec061cdd5910aa07369731c"}, @@ -813,10 +574,6 @@ colorlog = [ {file = "colorlog-4.1.0-py2.py3-none-any.whl", hash = "sha256:732c191ebbe9a353ec160d043d02c64ddef9028de8caae4cfa8bd49b6afed53e"}, {file = "colorlog-4.1.0.tar.gz", hash = "sha256:30aaef5ab2a1873dec5da38fd6ba568fa761c9fa10b40241027fa3edea47f3d2"}, ] -commonmark = [ - {file = "commonmark-0.9.0-py2.py3-none-any.whl", hash = "sha256:14c3df31e8c9c463377e287b2a1eefaa6019ab97b22dad36e2f32be59d61d68d"}, - {file = "commonmark-0.9.0.tar.gz", hash = "sha256:867fc5db078ede373ab811e16b6789e9d033b15ccd7296f370ca52d1ee792ce0"}, -] dataclasses = [ {file = "dataclasses-0.6-py3-none-any.whl", hash = "sha256:454a69d788c7fda44efd71e259be79577822f5e3f53f029a22d08004e951dc9f"}, {file = "dataclasses-0.6.tar.gz", hash = "sha256:6988bd2b895eef432d562370bb707d540f32f7360ab13da45340101bc2307d84"}, @@ -824,11 +581,6 @@ dataclasses = [ distlib = [ {file = "distlib-0.3.0.zip", hash = "sha256:2e166e231a26b36d6dfe35a48c4464346620f8645ed0ace01ee31822b288de21"}, ] -docutils = [ - {file = "docutils-0.14-py2-none-any.whl", hash = "sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6"}, - {file = "docutils-0.14-py3-none-any.whl", hash = "sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6"}, - {file = "docutils-0.14.tar.gz", hash = "sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274"}, -] filelock = [ {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, @@ -841,17 +593,10 @@ flake8-import-order = [ {file = "flake8-import-order-0.18.1.tar.gz", hash = "sha256:a28dc39545ea4606c1ac3c24e9d05c849c6e5444a50fb7e9cdd430fc94de6e92"}, {file = "flake8_import_order-0.18.1-py2.py3-none-any.whl", hash = "sha256:90a80e46886259b9c396b578d75c749801a41ee969a235e163cfe1be7afd2543"}, ] -future = [ - {file = "future-0.17.1.tar.gz", hash = "sha256:67045236dcfd6816dc439556d009594abf643e5eb48992e36beac09c2ca659b8"}, -] idna = [ {file = "idna-2.8-py2.py3-none-any.whl", hash = "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"}, {file = "idna-2.8.tar.gz", hash = "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407"}, ] -imagesize = [ - {file = "imagesize-1.1.0-py2.py3-none-any.whl", hash = "sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8"}, - {file = "imagesize-1.1.0.tar.gz", hash = "sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5"}, -] importlib-metadata = [ {file = "importlib_metadata-1.3.0-py2.py3-none-any.whl", hash = "sha256:d95141fbfa7ef2ec65cfd945e2af7e5a6ddbd7c8d9a25e66ff3be8e3daf9f60f"}, {file = "importlib_metadata-1.3.0.tar.gz", hash = "sha256:073a852570f92da5f744a3472af1b61e28e9f78ccf0c9117658dc32b15de7b45"}, @@ -860,40 +605,6 @@ importlib-resources = [ {file = "importlib_resources-1.5.0-py2.py3-none-any.whl", hash = "sha256:85dc0b9b325ff78c8bef2e4ff42616094e16b98ebd5e3b50fe7e2f0bbcdcde49"}, {file = "importlib_resources-1.5.0.tar.gz", hash = "sha256:6f87df66833e1942667108628ec48900e02a4ab4ad850e25fbf07cb17cf734ca"}, ] -jinja2 = [ - {file = "Jinja2-2.10.1-py2.py3-none-any.whl", hash = "sha256:14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b"}, - {file = "Jinja2-2.10.1.tar.gz", hash = "sha256:065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013"}, -] -markupsafe = [ - {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"}, - {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"}, - {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, - {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, -] mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, @@ -992,10 +703,6 @@ pyflakes = [ {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, ] -pygments = [ - {file = "Pygments-2.4.2-py2.py3-none-any.whl", hash = "sha256:71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127"}, - {file = "Pygments-2.4.2.tar.gz", hash = "sha256:881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297"}, -] pyparsing = [ {file = "pyparsing-2.4.0-py2.py3-none-any.whl", hash = "sha256:9b6323ef4ab914af344ba97510e966d64ba91055d6b9afa6b30799340e89cc03"}, {file = "pyparsing-2.4.0.tar.gz", hash = "sha256:1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a"}, @@ -1012,10 +719,6 @@ pytz = [ {file = "pytz-2019.1-py2.py3-none-any.whl", hash = "sha256:303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda"}, {file = "pytz-2019.1.tar.gz", hash = "sha256:d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141"}, ] -recommonmark = [ - {file = "recommonmark-0.6.0-py2.py3-none-any.whl", hash = "sha256:2ec4207a574289355d5b6ae4ae4abb29043346ca12cdd5f07d374dc5987d2852"}, - {file = "recommonmark-0.6.0.tar.gz", hash = "sha256:29cd4faeb6c5268c633634f2d69aef9431e0f4d347f90659fd0aab20e541efeb"}, -] requests = [ {file = "requests-2.22.0-py2.py3-none-any.whl", hash = "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"}, {file = "requests-2.22.0.tar.gz", hash = "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4"}, @@ -1051,50 +754,9 @@ six = [ {file = "six-1.12.0-py2.py3-none-any.whl", hash = "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c"}, {file = "six-1.12.0.tar.gz", hash = "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"}, ] -snowballstemmer = [ - {file = "snowballstemmer-1.2.1-py2.py3-none-any.whl", hash = "sha256:9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89"}, - {file = "snowballstemmer-1.2.1.tar.gz", hash = "sha256:919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128"}, -] -sphinx = [ - {file = "Sphinx-2.1.0-py2.py3-none-any.whl", hash = "sha256:2c5becc0fd6706dc0aeb4703f9f1f8a1d1eecacf02e9ac5943cbae48b11e5e42"}, - {file = "Sphinx-2.1.0.tar.gz", hash = "sha256:7a359a91fb04054ec77d68ff97cb8728f8cc322e25f22dc94299d67e0e6a7123"}, -] -sphinx-autodoc-typehints = [ - {file = "sphinx-autodoc-typehints-1.8.0.tar.gz", hash = "sha256:0d968ec3ee4f7fe7695ab6facf5cd2d74d3cea67584277458ad9b2788ebbcc3b"}, - {file = "sphinx_autodoc_typehints-1.8.0-py3-none-any.whl", hash = "sha256:8edca714fd3de8e43467d7e51dd3812fe999f8874408a639f7c38a9e1a5a4eb3"}, -] -sphinx-rtd-theme = [ - {file = "sphinx_rtd_theme-0.4.3-py2.py3-none-any.whl", hash = "sha256:00cf895504a7895ee433807c62094cf1e95f065843bf3acd17037c3e9a2becd4"}, - {file = "sphinx_rtd_theme-0.4.3.tar.gz", hash = "sha256:728607e34d60456d736cc7991fd236afb828b21b82f956c5ea75f94c8414040a"}, -] -sphinxcontrib-applehelp = [ - {file = "sphinxcontrib-applehelp-1.0.1.tar.gz", hash = "sha256:edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897"}, - {file = "sphinxcontrib_applehelp-1.0.1-py2.py3-none-any.whl", hash = "sha256:fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d"}, -] -sphinxcontrib-devhelp = [ - {file = "sphinxcontrib-devhelp-1.0.1.tar.gz", hash = "sha256:6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34"}, - {file = "sphinxcontrib_devhelp-1.0.1-py2.py3-none-any.whl", hash = "sha256:9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981"}, -] -sphinxcontrib-htmlhelp = [ - {file = "sphinxcontrib-htmlhelp-1.0.2.tar.gz", hash = "sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422"}, - {file = "sphinxcontrib_htmlhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7"}, -] -sphinxcontrib-jsmath = [ - {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, - {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, -] -sphinxcontrib-qthelp = [ - {file = "sphinxcontrib-qthelp-1.0.2.tar.gz", hash = "sha256:79465ce11ae5694ff165becda529a600c754f4bc459778778c7017374d4d406f"}, - {file = "sphinxcontrib_qthelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:513049b93031beb1f57d4daea74068a4feb77aa5630f856fcff2e50de14e9a20"}, -] -sphinxcontrib-serializinghtml = [ - {file = "sphinxcontrib-serializinghtml-1.1.3.tar.gz", hash = "sha256:c0efb33f8052c04fd7a26c0a07f1678e8512e0faec19f4aa8f2473a8b81d5227"}, - {file = "sphinxcontrib_serializinghtml-1.1.3-py2.py3-none-any.whl", hash = "sha256:db6615af393650bf1151a6cd39120c29abaf93cc60db8c48eb2dddbfdc3a9768"}, -] toml = [ - {file = "toml-0.10.0-py2.7.egg", hash = "sha256:f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"}, - {file = "toml-0.10.0-py2.py3-none-any.whl", hash = "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"}, - {file = "toml-0.10.0.tar.gz", hash = "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c"}, + {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"}, + {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"}, ] typed-ast = [ {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, diff --git a/pyproject.toml b/pyproject.toml index 06b86a25..978bcd37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,15 +33,10 @@ simplejson = "^3.16" dataclasses = "^0.6.0" [tool.poetry.dev-dependencies] -Sphinx = "^2.1" responses = "^0.10.6" flake8-import-order = "^0.18.1" black = {version = "^19.3b0",allow-prereleases = true} flake8 = "^3.7" -toml = "^0.10.0" -sphinx_rtd_theme = "^0.4.3" -recommonmark = "^0.6.0" -sphinx-autodoc-typehints = "^1.8" pytest = "^5.3.2" mypy = "^0.770" nox = "^2020.5.24" From 298e6372fdb2cb8a3b4bee5530819e52cdb8b20a Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 2 Jul 2020 10:42:35 -0400 Subject: [PATCH 463/632] Upgrade docs dependencies Removes many deprecation warnings for build output. --- docs/requirements.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index bbb50dcc..2b0c0ae9 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,8 +1,8 @@ -# TODO(pcattori) Delete this file once RTD fully supports poetry +# Doc dependencies tracked separately here for interoperability with readthedocs.org pandas==1.0.5 recommonmark==0.6.0 -sphinx_rtd_theme==0.4.3 -sphinx-autodoc-typehints==1.8.0 -Sphinx==2.1.0 +sphinx_rtd_theme==0.5.0 +sphinx-autodoc-typehints==1.11.0 +Sphinx==3.1.1 toml==0.10.0 . From 8f7d84b7cc3530e321d0ecda796003ce9916fefa Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 2 Jul 2020 10:44:30 -0400 Subject: [PATCH 464/632] Upgrade `black` format tool --- poetry.lock | 62 +++++++++++++++++++++++++++++++++++++++++++------- pyproject.toml | 2 +- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/poetry.lock b/poetry.lock index ff91eace..68fe1d78 100644 --- a/poetry.lock +++ b/poetry.lock @@ -50,13 +50,16 @@ description = "The uncompromising code formatter." name = "black" optional = false python-versions = ">=3.6" -version = "19.3b0" +version = "19.10b0" [package.dependencies] appdirs = "*" attrs = ">=18.1.0" click = ">=6.5" +pathspec = ">=0.6,<1" +regex = "*" toml = ">=0.9.4" +typed-ast = ">=1.4.0" [package.extras] d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] @@ -82,8 +85,8 @@ category = "dev" description = "Composable command line interface toolkit" name = "click" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "7.0" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "7.1.2" [[package]] category = "dev" @@ -300,6 +303,14 @@ pytz = ">=2017.2" [package.extras] test = ["pytest (>=4.0.2)", "pytest-xdist", "hypothesis (>=3.58)"] +[[package]] +category = "dev" +description = "Utility library for gitignore style pattern matching of file paths." +name = "pathspec" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "0.8.0" + [[package]] category = "dev" description = "plugin and hook calling mechanisms for python" @@ -393,6 +404,14 @@ optional = false python-versions = "*" version = "2019.1" +[[package]] +category = "dev" +description = "Alternative regular expression module, to replace re." +name = "regex" +optional = false +python-versions = "*" +version = "2020.6.8" + [[package]] category = "main" description = "Python HTTP for Humans." @@ -530,7 +549,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["pathlib2", "contextlib2", "unittest2"] [metadata] -content-hash = "40af657270d107cadba1da7a88b9702582fcd2eb4fbddadb8e2788a8b395f668" +content-hash = "9749cdb51d8516629b04def3f16fbbc31107d23dfe5aba2d72656a2df413b598" python-versions = "^3.6.1" [metadata.files] @@ -551,8 +570,8 @@ attrs = [ {file = "attrs-19.1.0.tar.gz", hash = "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399"}, ] black = [ - {file = "black-19.3b0-py36-none-any.whl", hash = "sha256:09a9dcb7c46ed496a9850b76e4e825d6049ecd38b611f1224857a79bd985a8cf"}, - {file = "black-19.3b0.tar.gz", hash = "sha256:68950ffd4d9169716bcb8719a56c07a2f4485354fec061cdd5910aa07369731c"}, + {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"}, + {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, ] certifi = [ {file = "certifi-2019.3.9-py2.py3-none-any.whl", hash = "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5"}, @@ -563,8 +582,8 @@ chardet = [ {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, ] click = [ - {file = "Click-7.0-py2.py3-none-any.whl", hash = "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13"}, - {file = "Click-7.0.tar.gz", hash = "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"}, + {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, + {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, ] colorama = [ {file = "colorama-0.4.1-py2.py3-none-any.whl", hash = "sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"}, @@ -687,6 +706,10 @@ pandas = [ {file = "pandas-1.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:4c73f373b0800eb3062ffd13d4a7a2a6d522792fa6eb204d67a4fad0a40f03dc"}, {file = "pandas-1.0.5.tar.gz", hash = "sha256:69c5d920a0b2a9838e677f78f4dde506b95ea8e4d30da25859db6469ded84fa8"}, ] +pathspec = [ + {file = "pathspec-0.8.0-py2.py3-none-any.whl", hash = "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0"}, + {file = "pathspec-0.8.0.tar.gz", hash = "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"}, +] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, @@ -719,6 +742,29 @@ pytz = [ {file = "pytz-2019.1-py2.py3-none-any.whl", hash = "sha256:303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda"}, {file = "pytz-2019.1.tar.gz", hash = "sha256:d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141"}, ] +regex = [ + {file = "regex-2020.6.8-cp27-cp27m-win32.whl", hash = "sha256:fbff901c54c22425a5b809b914a3bfaf4b9570eee0e5ce8186ac71eb2025191c"}, + {file = "regex-2020.6.8-cp27-cp27m-win_amd64.whl", hash = "sha256:112e34adf95e45158c597feea65d06a8124898bdeac975c9087fe71b572bd938"}, + {file = "regex-2020.6.8-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:92d8a043a4241a710c1cf7593f5577fbb832cf6c3a00ff3fc1ff2052aff5dd89"}, + {file = "regex-2020.6.8-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:bae83f2a56ab30d5353b47f9b2a33e4aac4de9401fb582b55c42b132a8ac3868"}, + {file = "regex-2020.6.8-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:b2ba0f78b3ef375114856cbdaa30559914d081c416b431f2437f83ce4f8b7f2f"}, + {file = "regex-2020.6.8-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:95fa7726d073c87141f7bbfb04c284901f8328e2d430eeb71b8ffdd5742a5ded"}, + {file = "regex-2020.6.8-cp36-cp36m-win32.whl", hash = "sha256:e3cdc9423808f7e1bb9c2e0bdb1c9dc37b0607b30d646ff6faf0d4e41ee8fee3"}, + {file = "regex-2020.6.8-cp36-cp36m-win_amd64.whl", hash = "sha256:c78e66a922de1c95a208e4ec02e2e5cf0bb83a36ceececc10a72841e53fbf2bd"}, + {file = "regex-2020.6.8-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:08997a37b221a3e27d68ffb601e45abfb0093d39ee770e4257bd2f5115e8cb0a"}, + {file = "regex-2020.6.8-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2f6f211633ee8d3f7706953e9d3edc7ce63a1d6aad0be5dcee1ece127eea13ae"}, + {file = "regex-2020.6.8-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:55b4c25cbb3b29f8d5e63aeed27b49fa0f8476b0d4e1b3171d85db891938cc3a"}, + {file = "regex-2020.6.8-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:89cda1a5d3e33ec9e231ece7307afc101b5217523d55ef4dc7fb2abd6de71ba3"}, + {file = "regex-2020.6.8-cp37-cp37m-win32.whl", hash = "sha256:690f858d9a94d903cf5cada62ce069b5d93b313d7d05456dbcd99420856562d9"}, + {file = "regex-2020.6.8-cp37-cp37m-win_amd64.whl", hash = "sha256:1700419d8a18c26ff396b3b06ace315b5f2a6e780dad387e4c48717a12a22c29"}, + {file = "regex-2020.6.8-cp38-cp38-manylinux1_i686.whl", hash = "sha256:654cb773b2792e50151f0e22be0f2b6e1c3a04c5328ff1d9d59c0398d37ef610"}, + {file = "regex-2020.6.8-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:52e1b4bef02f4040b2fd547357a170fc1146e60ab310cdbdd098db86e929b387"}, + {file = "regex-2020.6.8-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:cf59bbf282b627130f5ba68b7fa3abdb96372b24b66bdf72a4920e8153fc7910"}, + {file = "regex-2020.6.8-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:5aaa5928b039ae440d775acea11d01e42ff26e1561c0ffcd3d805750973c6baf"}, + {file = "regex-2020.6.8-cp38-cp38-win32.whl", hash = "sha256:97712e0d0af05febd8ab63d2ef0ab2d0cd9deddf4476f7aa153f76feef4b2754"}, + {file = "regex-2020.6.8-cp38-cp38-win_amd64.whl", hash = "sha256:6ad8663c17db4c5ef438141f99e291c4d4edfeaacc0ce28b5bba2b0bf273d9b5"}, + {file = "regex-2020.6.8.tar.gz", hash = "sha256:e9b64e609d37438f7d6e68c2546d2cb8062f3adb27e6336bc129b51be20773ac"}, +] requests = [ {file = "requests-2.22.0-py2.py3-none-any.whl", hash = "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"}, {file = "requests-2.22.0.tar.gz", hash = "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4"}, diff --git a/pyproject.toml b/pyproject.toml index 978bcd37..ae253cd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,12 +35,12 @@ dataclasses = "^0.6.0" [tool.poetry.dev-dependencies] responses = "^0.10.6" flake8-import-order = "^0.18.1" -black = {version = "^19.3b0",allow-prereleases = true} flake8 = "^3.7" pytest = "^5.3.2" mypy = "^0.770" nox = "^2020.5.24" pandas = "^1.0.5" +black = {version = "^19.10b0", allow-prereleases = true} [build-system] requires = ["poetry>=1.0"] From 1f2e99c8fa50bbdc0b48d406c14827610c1bb116 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 2 Jul 2020 10:52:11 -0400 Subject: [PATCH 465/632] Upgrade `mypy` typecheck tool --- poetry.lock | 40 ++++++++++++++++++++-------------------- pyproject.toml | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/poetry.lock b/poetry.lock index 68fe1d78..2d329265 100644 --- a/poetry.lock +++ b/poetry.lock @@ -228,7 +228,7 @@ description = "Optional static typing for Python" name = "mypy" optional = false python-versions = ">=3.5" -version = "0.770" +version = "0.782" [package.dependencies] mypy-extensions = ">=0.4.3,<0.5.0" @@ -483,7 +483,7 @@ description = "Backported and Experimental Type Hints for Python 3.5+" name = "typing-extensions" optional = false python-versions = "*" -version = "3.7.4.1" +version = "3.7.4.2" [[package]] category = "main" @@ -549,7 +549,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["pathlib2", "contextlib2", "unittest2"] [metadata] -content-hash = "9749cdb51d8516629b04def3f16fbbc31107d23dfe5aba2d72656a2df413b598" +content-hash = "eb1a510dfdc339cc884ea39cc411306e4593c01a46c13bb15e4aaf9fff85cad5" python-versions = "^3.6.1" [metadata.files] @@ -633,20 +633,20 @@ more-itertools = [ {file = "more_itertools-8.0.2-py3-none-any.whl", hash = "sha256:c833ef592a0324bcc6a60e48440da07645063c453880c9477ceb22490aec1564"}, ] mypy = [ - {file = "mypy-0.770-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:a34b577cdf6313bf24755f7a0e3f3c326d5c1f4fe7422d1d06498eb25ad0c600"}, - {file = "mypy-0.770-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:86c857510a9b7c3104cf4cde1568f4921762c8f9842e987bc03ed4f160925754"}, - {file = "mypy-0.770-cp35-cp35m-win_amd64.whl", hash = "sha256:a8ffcd53cb5dfc131850851cc09f1c44689c2812d0beb954d8138d4f5fc17f65"}, - {file = "mypy-0.770-cp36-cp36m-macosx_10_6_x86_64.whl", hash = "sha256:7687f6455ec3ed7649d1ae574136835a4272b65b3ddcf01ab8704ac65616c5ce"}, - {file = "mypy-0.770-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:3beff56b453b6ef94ecb2996bea101a08f1f8a9771d3cbf4988a61e4d9973761"}, - {file = "mypy-0.770-cp36-cp36m-win_amd64.whl", hash = "sha256:15b948e1302682e3682f11f50208b726a246ab4e6c1b39f9264a8796bb416aa2"}, - {file = "mypy-0.770-cp37-cp37m-macosx_10_6_x86_64.whl", hash = "sha256:b90928f2d9eb2f33162405f32dde9f6dcead63a0971ca8a1b50eb4ca3e35ceb8"}, - {file = "mypy-0.770-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:c56ffe22faa2e51054c5f7a3bc70a370939c2ed4de308c690e7949230c995913"}, - {file = "mypy-0.770-cp37-cp37m-win_amd64.whl", hash = "sha256:8dfb69fbf9f3aeed18afffb15e319ca7f8da9642336348ddd6cab2713ddcf8f9"}, - {file = "mypy-0.770-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:219a3116ecd015f8dca7b5d2c366c973509dfb9a8fc97ef044a36e3da66144a1"}, - {file = "mypy-0.770-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7ec45a70d40ede1ec7ad7f95b3c94c9cf4c186a32f6bacb1795b60abd2f9ef27"}, - {file = "mypy-0.770-cp38-cp38-win_amd64.whl", hash = "sha256:f91c7ae919bbc3f96cd5e5b2e786b2b108343d1d7972ea130f7de27fdd547cf3"}, - {file = "mypy-0.770-py3-none-any.whl", hash = "sha256:3b1fc683fb204c6b4403a1ef23f0b1fac8e4477091585e0c8c54cbdf7d7bb164"}, - {file = "mypy-0.770.tar.gz", hash = "sha256:8a627507ef9b307b46a1fea9513d5c98680ba09591253082b4c48697ba05a4ae"}, + {file = "mypy-0.782-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:2c6cde8aa3426c1682d35190b59b71f661237d74b053822ea3d748e2c9578a7c"}, + {file = "mypy-0.782-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9c7a9a7ceb2871ba4bac1cf7217a7dd9ccd44c27c2950edbc6dc08530f32ad4e"}, + {file = "mypy-0.782-cp35-cp35m-win_amd64.whl", hash = "sha256:c05b9e4fb1d8a41d41dec8786c94f3b95d3c5f528298d769eb8e73d293abc48d"}, + {file = "mypy-0.782-cp36-cp36m-macosx_10_6_x86_64.whl", hash = "sha256:6731603dfe0ce4352c555c6284c6db0dc935b685e9ce2e4cf220abe1e14386fd"}, + {file = "mypy-0.782-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:f05644db6779387ccdb468cc47a44b4356fc2ffa9287135d05b70a98dc83b89a"}, + {file = "mypy-0.782-cp36-cp36m-win_amd64.whl", hash = "sha256:b7fbfabdbcc78c4f6fc4712544b9b0d6bf171069c6e0e3cb82440dd10ced3406"}, + {file = "mypy-0.782-cp37-cp37m-macosx_10_6_x86_64.whl", hash = "sha256:3fdda71c067d3ddfb21da4b80e2686b71e9e5c72cca65fa216d207a358827f86"}, + {file = "mypy-0.782-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d7df6eddb6054d21ca4d3c6249cae5578cb4602951fd2b6ee2f5510ffb098707"}, + {file = "mypy-0.782-cp37-cp37m-win_amd64.whl", hash = "sha256:a4a2cbcfc4cbf45cd126f531dedda8485671545b43107ded25ce952aac6fb308"}, + {file = "mypy-0.782-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6bb93479caa6619d21d6e7160c552c1193f6952f0668cdda2f851156e85186fc"}, + {file = "mypy-0.782-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:81c7908b94239c4010e16642c9102bfc958ab14e36048fa77d0be3289dda76ea"}, + {file = "mypy-0.782-cp38-cp38-win_amd64.whl", hash = "sha256:5dd13ff1f2a97f94540fd37a49e5d255950ebcdf446fb597463a40d0df3fac8b"}, + {file = "mypy-0.782-py3-none-any.whl", hash = "sha256:e0b61738ab504e656d1fe4ff0c0601387a5489ca122d55390ade31f9ca0e252d"}, + {file = "mypy-0.782.tar.gz", hash = "sha256:eff7d4a85e9eea55afa34888dfeaccde99e7520b51f867ac28a48492c0b1130c"}, ] mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, @@ -828,9 +828,9 @@ typed-ast = [ {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, ] typing-extensions = [ - {file = "typing_extensions-3.7.4.1-py2-none-any.whl", hash = "sha256:910f4656f54de5993ad9304959ce9bb903f90aadc7c67a0bef07e678014e892d"}, - {file = "typing_extensions-3.7.4.1-py3-none-any.whl", hash = "sha256:cf8b63fedea4d89bab840ecbb93e75578af28f76f66c35889bd7065f5af88575"}, - {file = "typing_extensions-3.7.4.1.tar.gz", hash = "sha256:091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2"}, + {file = "typing_extensions-3.7.4.2-py2-none-any.whl", hash = "sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392"}, + {file = "typing_extensions-3.7.4.2-py3-none-any.whl", hash = "sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5"}, + {file = "typing_extensions-3.7.4.2.tar.gz", hash = "sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae"}, ] urllib3 = [ {file = "urllib3-1.25.3-py2.py3-none-any.whl", hash = "sha256:b246607a25ac80bedac05c6f282e3cdaf3afb65420fd024ac94435cabe6e18d1"}, diff --git a/pyproject.toml b/pyproject.toml index ae253cd0..00b6d5b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,10 +37,10 @@ responses = "^0.10.6" flake8-import-order = "^0.18.1" flake8 = "^3.7" pytest = "^5.3.2" -mypy = "^0.770" nox = "^2020.5.24" pandas = "^1.0.5" black = {version = "^19.10b0", allow-prereleases = true} +mypy = "^0.782" [build-system] requires = ["poetry>=1.0"] From 472572afaa0f6e1290311a49de8e2d4be628671d Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 2 Jul 2020 12:51:12 -0400 Subject: [PATCH 466/632] Move dataset types into _types/dataset.py Also, provide descriptions to the URL fields to disambiguate Dataset and UnifiedDataset. --- tamr_client/__init__.py | 10 ++++- tamr_client/_types/__init__.py | 1 + tamr_client/_types/dataset.py | 45 +++++++++++++++++++ tamr_client/attribute/_attribute.py | 3 +- tamr_client/dataset/__init__.py | 4 +- .../dataset/{dataset.py => _dataset.py} | 27 +---------- tamr_client/dataset/dataframe.py | 3 +- tamr_client/dataset/record.py | 3 +- tamr_client/dataset/unified.py | 23 +--------- 9 files changed, 60 insertions(+), 59 deletions(-) create mode 100644 tamr_client/_types/dataset.py rename tamr_client/dataset/{dataset.py => _dataset.py} (77%) diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 6774bac2..31bb2c17 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -16,7 +16,14 @@ # types ####### -from tamr_client._types import Attribute, AttributeType, SubAttribute, URL +from tamr_client._types import ( + Attribute, + AttributeType, + Dataset, + SubAttribute, + UnifiedDataset, + URL, +) # functions ########### @@ -36,7 +43,6 @@ from tamr_client import session # datasets -from tamr_client.dataset import AnyDataset, Dataset from tamr_client import dataset # records diff --git a/tamr_client/_types/__init__.py b/tamr_client/_types/__init__.py index 26018dec..dcf344a9 100644 --- a/tamr_client/_types/__init__.py +++ b/tamr_client/_types/__init__.py @@ -15,5 +15,6 @@ STRING, SubAttribute, ) +from tamr_client._types.dataset import AnyDataset, Dataset, UnifiedDataset from tamr_client._types.json import JsonDict from tamr_client._types.url import URL diff --git a/tamr_client/_types/dataset.py b/tamr_client/_types/dataset.py new file mode 100644 index 00000000..15c6227a --- /dev/null +++ b/tamr_client/_types/dataset.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass +from typing import Optional, Tuple, Union + +from tamr_client._types.url import URL + + +@dataclass(frozen=True) +class Dataset: + """A Tamr dataset + + See https://docs.tamr.com/reference/dataset-models + + Args: + url: The canonical dataset-based URL for this dataset e.g. `/datasets/1` + name + key_attribute_names + description + """ + + url: URL + name: str + key_attribute_names: Tuple[str, ...] + description: Optional[str] = None + + +@dataclass(frozen=True) +class UnifiedDataset: + """A Tamr unified dataset + + See https://docs.tamr.com/reference/dataset-models + + Args: + url: The project-based alias for this dataset e.g. `/projects/1/unifiedDataset` + name + key_attribute_names + description + """ + + url: URL + name: str + key_attribute_names: Tuple[str, ...] + description: Optional[str] = None + + +AnyDataset = Union[Dataset, UnifiedDataset] diff --git a/tamr_client/attribute/_attribute.py b/tamr_client/attribute/_attribute.py index 26fbc5ed..90684f63 100644 --- a/tamr_client/attribute/_attribute.py +++ b/tamr_client/attribute/_attribute.py @@ -6,9 +6,8 @@ from typing import Optional, Tuple from tamr_client import response -from tamr_client._types import Attribute, AttributeType, JsonDict, URL +from tamr_client._types import Attribute, AttributeType, Dataset, JsonDict, URL from tamr_client.attribute import type as attribute_type -from tamr_client.dataset.dataset import Dataset from tamr_client.session import Session diff --git a/tamr_client/dataset/__init__.py b/tamr_client/dataset/__init__.py index e873d51a..a78952af 100644 --- a/tamr_client/dataset/__init__.py +++ b/tamr_client/dataset/__init__.py @@ -1,4 +1,2 @@ from tamr_client.dataset import dataframe, record, unified -from tamr_client.dataset.dataset import AnyDataset, Dataset -from tamr_client.dataset.dataset import from_resource_id -from tamr_client.dataset.dataset import NotFound +from tamr_client.dataset._dataset import from_resource_id, NotFound diff --git a/tamr_client/dataset/dataset.py b/tamr_client/dataset/_dataset.py similarity index 77% rename from tamr_client/dataset/dataset.py rename to tamr_client/dataset/_dataset.py index 67c54e80..403584b2 100644 --- a/tamr_client/dataset/dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -2,12 +2,9 @@ See https://docs.tamr.com/reference/dataset-models """ from copy import deepcopy -from dataclasses import dataclass -from typing import Optional, Tuple, Union from tamr_client import response -from tamr_client._types import JsonDict, URL -from tamr_client.dataset.unified import UnifiedDataset +from tamr_client._types import Dataset, JsonDict, URL from tamr_client.instance import Instance from tamr_client.session import Session @@ -20,28 +17,6 @@ class NotFound(Exception): pass -@dataclass(frozen=True) -class Dataset: - """A Tamr dataset - - See https://docs.tamr.com/reference/dataset-models - - Args: - url - name - key_attribute_names - description - """ - - url: URL - name: str - key_attribute_names: Tuple[str, ...] - description: Optional[str] = None - - -AnyDataset = Union[Dataset, UnifiedDataset] - - def from_resource_id(session: Session, instance: Instance, id: str) -> Dataset: """Get dataset by resource ID diff --git a/tamr_client/dataset/dataframe.py b/tamr_client/dataset/dataframe.py index 909c4586..289433f9 100644 --- a/tamr_client/dataset/dataframe.py +++ b/tamr_client/dataset/dataframe.py @@ -6,9 +6,8 @@ import os from typing import Optional, TYPE_CHECKING -from tamr_client._types import JsonDict +from tamr_client._types import Dataset, JsonDict from tamr_client.dataset import record -from tamr_client.dataset.dataset import Dataset from tamr_client.session import Session BUILDING_DOCS = os.environ.get("TAMR_CLIENT_DOCS") == "1" diff --git a/tamr_client/dataset/record.py b/tamr_client/dataset/record.py index 2d609ade..383a96df 100644 --- a/tamr_client/dataset/record.py +++ b/tamr_client/dataset/record.py @@ -8,8 +8,7 @@ from typing import cast, Dict, IO, Iterable, Iterator, Optional from tamr_client import response -from tamr_client._types import JsonDict -from tamr_client.dataset.dataset import AnyDataset, Dataset +from tamr_client._types import AnyDataset, Dataset, JsonDict from tamr_client.session import Session diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index 22e299b4..406ce845 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -2,11 +2,9 @@ See https://docs.tamr.com/reference/dataset-models """ from copy import deepcopy -from dataclasses import dataclass -from typing import Optional, Tuple from tamr_client import operation, response -from tamr_client._types import JsonDict, URL +from tamr_client._types import JsonDict, UnifiedDataset, URL from tamr_client.instance import Instance from tamr_client.operation import Operation from tamr_client.project import Project @@ -21,25 +19,6 @@ class NotFound(Exception): pass -@dataclass(frozen=True) -class UnifiedDataset: - """A Tamr unified dataset - - See https://docs.tamr.com/reference/dataset-models - - Args: - url - name - key_attribute_names - description - """ - - url: URL - name: str - key_attribute_names: Tuple[str, ...] - description: Optional[str] = None - - def from_project( session: Session, instance: Instance, project: Project ) -> UnifiedDataset: From 415a87dae5e3a9cc2c16def1a6270a13b9705aa2 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 2 Jul 2020 13:18:46 -0400 Subject: [PATCH 467/632] Move project types into _types/project.py Relates to #400 --- docs/beta/mastering/project.rst | 2 +- tamr_client/__init__.py | 3 +++ tamr_client/_types/__init__.py | 1 + tamr_client/_types/project.py | 24 ++++++++++++++++++++++++ tamr_client/mastering/__init__.py | 3 +-- tamr_client/mastering/project.py | 26 +++----------------------- tamr_client/project.py | 8 +------- tests/tamr_client/test_project.py | 2 +- tests/tamr_client/utils.py | 2 +- 9 files changed, 36 insertions(+), 35 deletions(-) create mode 100644 tamr_client/_types/project.py diff --git a/docs/beta/mastering/project.rst b/docs/beta/mastering/project.rst index 15928d0a..cc73158b 100644 --- a/docs/beta/mastering/project.rst +++ b/docs/beta/mastering/project.rst @@ -1,4 +1,4 @@ Mastering Project ================= -.. autoclass:: tamr_client.mastering.Project +.. autoclass:: tamr_client.MasteringProject diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 31bb2c17..94415f1f 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -17,9 +17,12 @@ ####### from tamr_client._types import ( + AnyDataset, Attribute, AttributeType, Dataset, + MasteringProject, + Project, SubAttribute, UnifiedDataset, URL, diff --git a/tamr_client/_types/__init__.py b/tamr_client/_types/__init__.py index dcf344a9..04d19701 100644 --- a/tamr_client/_types/__init__.py +++ b/tamr_client/_types/__init__.py @@ -17,4 +17,5 @@ ) from tamr_client._types.dataset import AnyDataset, Dataset, UnifiedDataset from tamr_client._types.json import JsonDict +from tamr_client._types.project import MasteringProject, Project from tamr_client._types.url import URL diff --git a/tamr_client/_types/project.py b/tamr_client/_types/project.py new file mode 100644 index 00000000..6df4fbc3 --- /dev/null +++ b/tamr_client/_types/project.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass +from typing import Optional, Union + +from tamr_client._types.url import URL + + +@dataclass(frozen=True) +class MasteringProject: + """A Tamr Mastering project + + See https://docs.tamr.com/reference/the-project-object + + Args: + url + name + description + """ + + url: URL + name: str + description: Optional[str] = None + + +Project = Union[MasteringProject] diff --git a/tamr_client/mastering/__init__.py b/tamr_client/mastering/__init__.py index be36bf14..db75a977 100644 --- a/tamr_client/mastering/__init__.py +++ b/tamr_client/mastering/__init__.py @@ -2,5 +2,4 @@ Tamr - Mastering See https://docs.tamr.com/docs/overall-workflow-mastering """ -import tamr_client.mastering.project -from tamr_client.mastering.project import Project +from tamr_client.mastering import project diff --git a/tamr_client/mastering/project.py b/tamr_client/mastering/project.py index 2faa1173..ea8300fc 100644 --- a/tamr_client/mastering/project.py +++ b/tamr_client/mastering/project.py @@ -1,31 +1,11 @@ -from dataclasses import dataclass -from typing import Optional +from tamr_client._types import JsonDict, MasteringProject, URL -from tamr_client._types import JsonDict, URL - -@dataclass(frozen=True) -class Project: - """A Tamr Mastering project - - See https://docs.tamr.com/reference/the-project-object - - Args: - url - name - description - """ - - url: URL - name: str - description: Optional[str] = None - - -def _from_json(url: URL, data: JsonDict) -> Project: +def _from_json(url: URL, data: JsonDict) -> MasteringProject: """Make mastering project from JSON data (deserialize) Args: url: Project URL data: Project JSON data from Tamr server """ - return Project(url, name=data["name"], description=data.get("description")) + return MasteringProject(url, name=data["name"], description=data.get("description")) diff --git a/tamr_client/project.py b/tamr_client/project.py index e6784732..22b28959 100644 --- a/tamr_client/project.py +++ b/tamr_client/project.py @@ -1,16 +1,10 @@ -from typing import Union - from tamr_client import response -from tamr_client._types import JsonDict, URL +from tamr_client._types import JsonDict, Project, URL from tamr_client.instance import Instance from tamr_client.mastering import project as mastering_project -from tamr_client.mastering.project import Project as MasteringProject from tamr_client.session import Session -Project = Union[MasteringProject] - - class NotFound(Exception): """Raised when referencing (e.g. updating or deleting) a project that does not exist on the server.""" diff --git a/tests/tamr_client/test_project.py b/tests/tamr_client/test_project.py index bfb78c9d..e6afe76e 100644 --- a/tests/tamr_client/test_project.py +++ b/tests/tamr_client/test_project.py @@ -15,7 +15,7 @@ def test_from_resource_id_mastering(): responses.add(responses.GET, str(url), json=project_json) project = tc.project.from_resource_id(s, instance, "1") - assert isinstance(project, tc.mastering.Project) + assert isinstance(project, tc.MasteringProject) assert project.name == "proj" assert project.description == "Mastering Project" diff --git a/tests/tamr_client/utils.py b/tests/tamr_client/utils.py index 95b4af11..3966c768 100644 --- a/tests/tamr_client/utils.py +++ b/tests/tamr_client/utils.py @@ -38,7 +38,7 @@ def unified_dataset(): def mastering_project(): url = tc.URL(path="projects/1") - mastering_project = tc.mastering.Project( + mastering_project = tc.MasteringProject( url, name="Project 1", description="A Mastering Project" ) return mastering_project From a4901bd57a29d2184394ef4b4b7ce030a9d845d7 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 2 Jul 2020 13:28:03 -0400 Subject: [PATCH 468/632] Move operation type into _types/operation.py --- tamr_client/__init__.py | 2 +- tamr_client/_types/__init__.py | 1 + tamr_client/_types/operation.py | 23 +++++++++++++++++++++++ tamr_client/dataset/unified.py | 3 +-- tamr_client/operation.py | 24 ++---------------------- 5 files changed, 28 insertions(+), 25 deletions(-) create mode 100644 tamr_client/_types/operation.py diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 94415f1f..6e6b8d30 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -22,6 +22,7 @@ AttributeType, Dataset, MasteringProject, + Operation, Project, SubAttribute, UnifiedDataset, @@ -63,5 +64,4 @@ from tamr_client import project # operations -from tamr_client.operation import Operation from tamr_client import operation diff --git a/tamr_client/_types/__init__.py b/tamr_client/_types/__init__.py index 04d19701..2bcd5905 100644 --- a/tamr_client/_types/__init__.py +++ b/tamr_client/_types/__init__.py @@ -17,5 +17,6 @@ ) from tamr_client._types.dataset import AnyDataset, Dataset, UnifiedDataset from tamr_client._types.json import JsonDict +from tamr_client._types.operation import Operation from tamr_client._types.project import MasteringProject, Project from tamr_client._types.url import URL diff --git a/tamr_client/_types/operation.py b/tamr_client/_types/operation.py new file mode 100644 index 00000000..a6462a42 --- /dev/null +++ b/tamr_client/_types/operation.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from typing import Dict, Optional + +from tamr_client._types.url import URL + + +@dataclass(frozen=True) +class Operation: + """A Tamr operation + + See https://docs.tamr.com/new/reference/the-operation-object + + Args: + url + type + status + description + """ + + url: URL + type: str + status: Optional[Dict[str, str]] = None + description: Optional[str] = None diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index 406ce845..1a3e511b 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -4,9 +4,8 @@ from copy import deepcopy from tamr_client import operation, response -from tamr_client._types import JsonDict, UnifiedDataset, URL +from tamr_client._types import JsonDict, Operation, UnifiedDataset, URL from tamr_client.instance import Instance -from tamr_client.operation import Operation from tamr_client.project import Project from tamr_client.session import Session diff --git a/tamr_client/operation.py b/tamr_client/operation.py index feab9fcd..c32d79c9 100644 --- a/tamr_client/operation.py +++ b/tamr_client/operation.py @@ -2,14 +2,13 @@ See https://docs.tamr.com/new/reference/the-operation-object """ from copy import deepcopy -from dataclasses import dataclass from time import sleep, time as now -from typing import Dict, Optional +from typing import Optional import requests from tamr_client import response -from tamr_client._types import JsonDict, URL +from tamr_client._types import JsonDict, Operation, URL from tamr_client.instance import Instance from tamr_client.session import Session @@ -21,25 +20,6 @@ class NotFound(Exception): pass -@dataclass(frozen=True) -class Operation: - """A Tamr operation - - See https://docs.tamr.com/new/reference/the-operation-object - - Args: - url - type - status - description - """ - - url: URL - type: str - status: Optional[Dict[str, str]] = None - description: Optional[str] = None - - def poll(session: Session, operation: Operation) -> Operation: """Poll this operation for server-side updates. From 1a9247897da4bc244b3f1360f17fec85c3f98e9b Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 2 Jul 2020 13:37:31 -0400 Subject: [PATCH 469/632] Move instance type to _types/instance.py Also, include a docstring for the Instance dataclass --- tamr_client/__init__.py | 2 +- tamr_client/_types/__init__.py | 1 + tamr_client/_types/instance.py | 17 +++++++++++++++++ tamr_client/_types/url.py | 5 +++-- tamr_client/dataset/_dataset.py | 3 +-- tamr_client/dataset/unified.py | 3 +-- tamr_client/instance.py | 10 +--------- tamr_client/operation.py | 3 +-- tamr_client/project.py | 3 +-- 9 files changed, 27 insertions(+), 20 deletions(-) create mode 100644 tamr_client/_types/instance.py diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 6e6b8d30..1c42e77a 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -21,6 +21,7 @@ Attribute, AttributeType, Dataset, + Instance, MasteringProject, Operation, Project, @@ -36,7 +37,6 @@ from tamr_client import response # instance -from tamr_client.instance import Instance from tamr_client import instance # auth diff --git a/tamr_client/_types/__init__.py b/tamr_client/_types/__init__.py index 2bcd5905..3dfa4cba 100644 --- a/tamr_client/_types/__init__.py +++ b/tamr_client/_types/__init__.py @@ -16,6 +16,7 @@ SubAttribute, ) from tamr_client._types.dataset import AnyDataset, Dataset, UnifiedDataset +from tamr_client._types.instance import Instance from tamr_client._types.json import JsonDict from tamr_client._types.operation import Operation from tamr_client._types.project import MasteringProject, Project diff --git a/tamr_client/_types/instance.py b/tamr_client/_types/instance.py new file mode 100644 index 00000000..1826ba21 --- /dev/null +++ b/tamr_client/_types/instance.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class Instance: + """Connection parameters for a running Tamr instance + + Args: + protocol + host + port + """ + + protocol: str = "http" + host: str = "localhost" + port: Optional[int] = None diff --git a/tamr_client/_types/url.py b/tamr_client/_types/url.py index 428200ee..54a3496f 100644 --- a/tamr_client/_types/url.py +++ b/tamr_client/_types/url.py @@ -1,7 +1,6 @@ from dataclasses import dataclass -from tamr_client import instance -from tamr_client.instance import Instance +from tamr_client._types.instance import Instance @dataclass(frozen=True) @@ -11,5 +10,7 @@ class URL: base_path: str = "api/versioned/v1" def __str__(self): + from tamr_client import instance + origin = instance.origin(self.instance) return f"{origin}/{self.base_path}/{self.path}" diff --git a/tamr_client/dataset/_dataset.py b/tamr_client/dataset/_dataset.py index 403584b2..a7e39a36 100644 --- a/tamr_client/dataset/_dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -4,8 +4,7 @@ from copy import deepcopy from tamr_client import response -from tamr_client._types import Dataset, JsonDict, URL -from tamr_client.instance import Instance +from tamr_client._types import Dataset, Instance, JsonDict, URL from tamr_client.session import Session diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index 1a3e511b..377ac22b 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -4,8 +4,7 @@ from copy import deepcopy from tamr_client import operation, response -from tamr_client._types import JsonDict, Operation, UnifiedDataset, URL -from tamr_client.instance import Instance +from tamr_client._types import Instance, JsonDict, Operation, UnifiedDataset, URL from tamr_client.project import Project from tamr_client.session import Session diff --git a/tamr_client/instance.py b/tamr_client/instance.py index 29b974b5..10649878 100644 --- a/tamr_client/instance.py +++ b/tamr_client/instance.py @@ -1,12 +1,4 @@ -from dataclasses import dataclass -from typing import Optional - - -@dataclass(frozen=True) -class Instance: - protocol: str = "http" - host: str = "localhost" - port: Optional[int] = None +from tamr_client._types import Instance def origin(instance: Instance) -> str: diff --git a/tamr_client/operation.py b/tamr_client/operation.py index c32d79c9..4c6a1920 100644 --- a/tamr_client/operation.py +++ b/tamr_client/operation.py @@ -8,8 +8,7 @@ import requests from tamr_client import response -from tamr_client._types import JsonDict, Operation, URL -from tamr_client.instance import Instance +from tamr_client._types import Instance, JsonDict, Operation, URL from tamr_client.session import Session diff --git a/tamr_client/project.py b/tamr_client/project.py index 22b28959..9e43d903 100644 --- a/tamr_client/project.py +++ b/tamr_client/project.py @@ -1,6 +1,5 @@ from tamr_client import response -from tamr_client._types import JsonDict, Project, URL -from tamr_client.instance import Instance +from tamr_client._types import Instance, JsonDict, Project, URL from tamr_client.mastering import project as mastering_project from tamr_client.session import Session From d8673c1491603f7a730670ed6aba7d920f1b9e23 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 2 Jul 2020 13:49:21 -0400 Subject: [PATCH 470/632] Move session type into _types/session.py --- tamr_client/__init__.py | 2 +- tamr_client/_types/__init__.py | 1 + tamr_client/_types/session.py | 3 +++ tamr_client/attribute/_attribute.py | 3 +-- tamr_client/dataset/_dataset.py | 3 +-- tamr_client/dataset/dataframe.py | 3 +-- tamr_client/dataset/record.py | 3 +-- tamr_client/dataset/unified.py | 10 ++++++++-- tamr_client/operation.py | 3 +-- tamr_client/project.py | 3 +-- tamr_client/session.py | 2 +- 11 files changed, 20 insertions(+), 16 deletions(-) create mode 100644 tamr_client/_types/session.py diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 1c42e77a..1bc9ede9 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -25,6 +25,7 @@ MasteringProject, Operation, Project, + Session, SubAttribute, UnifiedDataset, URL, @@ -43,7 +44,6 @@ from tamr_client.auth import UsernamePasswordAuth # session -from tamr_client.session import Session from tamr_client import session # datasets diff --git a/tamr_client/_types/__init__.py b/tamr_client/_types/__init__.py index 3dfa4cba..c9deea89 100644 --- a/tamr_client/_types/__init__.py +++ b/tamr_client/_types/__init__.py @@ -20,4 +20,5 @@ from tamr_client._types.json import JsonDict from tamr_client._types.operation import Operation from tamr_client._types.project import MasteringProject, Project +from tamr_client._types.session import Session from tamr_client._types.url import URL diff --git a/tamr_client/_types/session.py b/tamr_client/_types/session.py new file mode 100644 index 00000000..036af1a1 --- /dev/null +++ b/tamr_client/_types/session.py @@ -0,0 +1,3 @@ +import requests + +Session = requests.Session diff --git a/tamr_client/attribute/_attribute.py b/tamr_client/attribute/_attribute.py index 90684f63..54ec90b0 100644 --- a/tamr_client/attribute/_attribute.py +++ b/tamr_client/attribute/_attribute.py @@ -6,9 +6,8 @@ from typing import Optional, Tuple from tamr_client import response -from tamr_client._types import Attribute, AttributeType, Dataset, JsonDict, URL +from tamr_client._types import Attribute, AttributeType, Dataset, JsonDict, Session, URL from tamr_client.attribute import type as attribute_type -from tamr_client.session import Session _RESERVED_NAMES = frozenset( diff --git a/tamr_client/dataset/_dataset.py b/tamr_client/dataset/_dataset.py index a7e39a36..55930b36 100644 --- a/tamr_client/dataset/_dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -4,8 +4,7 @@ from copy import deepcopy from tamr_client import response -from tamr_client._types import Dataset, Instance, JsonDict, URL -from tamr_client.session import Session +from tamr_client._types import Dataset, Instance, JsonDict, Session, URL class NotFound(Exception): diff --git a/tamr_client/dataset/dataframe.py b/tamr_client/dataset/dataframe.py index 289433f9..2913475b 100644 --- a/tamr_client/dataset/dataframe.py +++ b/tamr_client/dataset/dataframe.py @@ -6,9 +6,8 @@ import os from typing import Optional, TYPE_CHECKING -from tamr_client._types import Dataset, JsonDict +from tamr_client._types import Dataset, JsonDict, Session from tamr_client.dataset import record -from tamr_client.session import Session BUILDING_DOCS = os.environ.get("TAMR_CLIENT_DOCS") == "1" if TYPE_CHECKING or BUILDING_DOCS: diff --git a/tamr_client/dataset/record.py b/tamr_client/dataset/record.py index 383a96df..404daa7b 100644 --- a/tamr_client/dataset/record.py +++ b/tamr_client/dataset/record.py @@ -8,8 +8,7 @@ from typing import cast, Dict, IO, Iterable, Iterator, Optional from tamr_client import response -from tamr_client._types import AnyDataset, Dataset, JsonDict -from tamr_client.session import Session +from tamr_client._types import AnyDataset, Dataset, JsonDict, Session class PrimaryKeyNotFound(Exception): diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index 377ac22b..2cc5b12a 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -4,9 +4,15 @@ from copy import deepcopy from tamr_client import operation, response -from tamr_client._types import Instance, JsonDict, Operation, UnifiedDataset, URL +from tamr_client._types import ( + Instance, + JsonDict, + Operation, + Session, + UnifiedDataset, + URL, +) from tamr_client.project import Project -from tamr_client.session import Session class NotFound(Exception): diff --git a/tamr_client/operation.py b/tamr_client/operation.py index 4c6a1920..ac767c37 100644 --- a/tamr_client/operation.py +++ b/tamr_client/operation.py @@ -8,8 +8,7 @@ import requests from tamr_client import response -from tamr_client._types import Instance, JsonDict, Operation, URL -from tamr_client.session import Session +from tamr_client._types import Instance, JsonDict, Operation, Session, URL class NotFound(Exception): diff --git a/tamr_client/project.py b/tamr_client/project.py index 9e43d903..eedd787e 100644 --- a/tamr_client/project.py +++ b/tamr_client/project.py @@ -1,7 +1,6 @@ from tamr_client import response -from tamr_client._types import Instance, JsonDict, Project, URL +from tamr_client._types import Instance, JsonDict, Project, Session, URL from tamr_client.mastering import project as mastering_project -from tamr_client.session import Session class NotFound(Exception): diff --git a/tamr_client/session.py b/tamr_client/session.py index e8ea5728..c1676685 100644 --- a/tamr_client/session.py +++ b/tamr_client/session.py @@ -1,6 +1,6 @@ import requests -Session = requests.Session +from tamr_client._types import Session def from_auth(auth: requests.auth.HTTPBasicAuth) -> Session: From ca03c96650a63785d961113b6678f0cb6ddeed4f Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 2 Jul 2020 13:53:02 -0400 Subject: [PATCH 471/632] Move UsernamePasswordAuth type to _types/auth.py --- tamr_client/__init__.py | 4 +--- tamr_client/_types/__init__.py | 1 + tamr_client/{ => _types}/auth.py | 0 3 files changed, 2 insertions(+), 3 deletions(-) rename tamr_client/{ => _types}/auth.py (100%) diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 1bc9ede9..f193cab4 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -29,6 +29,7 @@ SubAttribute, UnifiedDataset, URL, + UsernamePasswordAuth, ) # functions @@ -40,9 +41,6 @@ # instance from tamr_client import instance -# auth -from tamr_client.auth import UsernamePasswordAuth - # session from tamr_client import session diff --git a/tamr_client/_types/__init__.py b/tamr_client/_types/__init__.py index c9deea89..c165992a 100644 --- a/tamr_client/_types/__init__.py +++ b/tamr_client/_types/__init__.py @@ -15,6 +15,7 @@ STRING, SubAttribute, ) +from tamr_client._types.auth import UsernamePasswordAuth from tamr_client._types.dataset import AnyDataset, Dataset, UnifiedDataset from tamr_client._types.instance import Instance from tamr_client._types.json import JsonDict diff --git a/tamr_client/auth.py b/tamr_client/_types/auth.py similarity index 100% rename from tamr_client/auth.py rename to tamr_client/_types/auth.py From eaf03b5c03d23b083459c9ae459375ea9ed4c36c Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 2 Jul 2020 13:58:33 -0400 Subject: [PATCH 472/632] Fix Project import to be directly from _types package --- tamr_client/dataset/unified.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index 2cc5b12a..a4428053 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -8,11 +8,11 @@ Instance, JsonDict, Operation, + Project, Session, UnifiedDataset, URL, ) -from tamr_client.project import Project class NotFound(Exception): From e7ed6bed7f290065a5de9285c14f19a9d2c2e1b5 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 2 Jul 2020 14:16:35 -0400 Subject: [PATCH 473/632] Consolidate primary key exceptions into their own module --- docs/beta.md | 1 + docs/beta/dataset/record.rst | 8 +------- docs/beta/primary_key.rst | 11 +++++++++++ tamr_client/__init__.py | 4 ++-- tamr_client/dataset/dataframe.py | 13 ++++--------- tamr_client/dataset/record.py | 19 +++++++------------ tamr_client/primary_key.py | 10 ++++++++++ tests/tamr_client/dataset/test_dataframe.py | 4 ++-- tests/tamr_client/dataset/test_record.py | 4 ++-- 9 files changed, 40 insertions(+), 34 deletions(-) create mode 100644 docs/beta/primary_key.rst create mode 100644 tamr_client/primary_key.py diff --git a/docs/beta.md b/docs/beta.md index 7a5feeb4..14551f42 100644 --- a/docs/beta.md +++ b/docs/beta.md @@ -11,6 +11,7 @@ * [Instance](beta/instance) * [Mastering](beta/mastering) * [Operation](beta/operation) + * [Primary Key](beta/primary_key) * [Project](beta/project) * [Response](beta/response) * [Session](beta/session) diff --git a/docs/beta/dataset/record.rst b/docs/beta/dataset/record.rst index 133bcf00..ab49772f 100644 --- a/docs/beta/dataset/record.rst +++ b/docs/beta/dataset/record.rst @@ -7,10 +7,4 @@ Record .. autofunction:: tamr_client.record.upsert .. autofunction:: tamr_client.record.delete .. autofunction:: tamr_client.record._update -.. autofunction:: tamr_client.record.stream - -Exceptions ----------- - -.. autoclass:: tamr_client.PrimaryKeyNotFound - :no-inherited-members: +.. autofunction:: tamr_client.record.stream \ No newline at end of file diff --git a/docs/beta/primary_key.rst b/docs/beta/primary_key.rst new file mode 100644 index 00000000..bd4462a9 --- /dev/null +++ b/docs/beta/primary_key.rst @@ -0,0 +1,11 @@ +Primary Key +=========== + +Exceptions +---------- + +.. autoclass:: tamr_client.primary_key.Ambiguous + :no-inherited-members: + +.. autoclass:: tamr_client.primary_key.NotFound + :no-inherited-members: diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index f193cab4..130524a9 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -44,15 +44,15 @@ # session from tamr_client import session +from tamr_client import primary_key + # datasets from tamr_client import dataset # records -from tamr_client.dataset.record import PrimaryKeyNotFound from tamr_client.dataset import record # dataframe -from tamr_client.dataset.dataframe import AmbiguousPrimaryKey from tamr_client.dataset import dataframe # attributes diff --git a/tamr_client/dataset/dataframe.py b/tamr_client/dataset/dataframe.py index 2913475b..d5023454 100644 --- a/tamr_client/dataset/dataframe.py +++ b/tamr_client/dataset/dataframe.py @@ -6,6 +6,7 @@ import os from typing import Optional, TYPE_CHECKING +from tamr_client import primary_key from tamr_client._types import Dataset, JsonDict, Session from tamr_client.dataset import record @@ -14,12 +15,6 @@ import pandas as pd -class AmbiguousPrimaryKey(Exception): - """Raised when referencing a primary key by name that matches multiple possible targets.""" - - pass - - def upsert( session: Session, dataset: Dataset, @@ -40,7 +35,7 @@ def upsert( Raises: requests.HTTPError: If an HTTP error is encountered requests.HTTPError: If an HTTP error is encountered - PrimaryKeyNotFound: If `primary_key_name` is not a column in `df` or the index of `df` + primary_key.NotFound: If `primary_key_name` is not a column in `df` or the index of `df` ValueError: If `primary_key_name` matches both a column in `df` and the index of `df` """ if primary_key_name is None: @@ -48,11 +43,11 @@ def upsert( # preconditions if primary_key_name in df.columns and primary_key_name == df.index.name: - raise AmbiguousPrimaryKey( + raise primary_key.Ambiguous( f"Index {primary_key_name} has the same name as column {primary_key_name}" ) elif primary_key_name not in df.columns and primary_key_name != df.index.name: - raise record.PrimaryKeyNotFound( + raise primary_key.NotFound( f"Primary key: {primary_key_name} is not DataFrame index name: {df.index.name} or in DataFrame column names: {df.columns}" ) diff --git a/tamr_client/dataset/record.py b/tamr_client/dataset/record.py index 404daa7b..a2a8b75c 100644 --- a/tamr_client/dataset/record.py +++ b/tamr_client/dataset/record.py @@ -7,16 +7,11 @@ import json from typing import cast, Dict, IO, Iterable, Iterator, Optional +from tamr_client import primary_key from tamr_client import response from tamr_client._types import AnyDataset, Dataset, JsonDict, Session -class PrimaryKeyNotFound(Exception): - """Raised when referencing a primary key by name that does not exist.""" - - pass - - def _update(session: Session, dataset: Dataset, updates: Iterable[Dict]) -> JsonDict: """Send a batch of record creations/updates/deletions to this dataset. You probably want to use :func:`~tamr_client.record.upsert` @@ -63,14 +58,14 @@ def upsert( Raises: requests.HTTPError: If an HTTP error is encountered - PrimaryKeyNotFound: If primary_key_name does not match dataset primary key - PrimaryKeyNotFound: If primary_key_name not in a record dictionary + primary_key.NotFound: If primary_key_name does not match dataset primary key + primary_key.NotFound: If primary_key_name not in a record dictionary """ if primary_key_name is None: primary_key_name = dataset.key_attribute_names[0] if primary_key_name not in dataset.key_attribute_names: - raise PrimaryKeyNotFound( + raise primary_key.NotFound( f"Primary key: {primary_key_name} is not in dataset key attribute names: {dataset.key_attribute_names}" ) updates = ( @@ -99,14 +94,14 @@ def delete( Raises: requests.HTTPError: If an HTTP error is encountered - PrimaryKeyNotFound: If primary_key_name does not match dataset primary key - PrimaryKeyNotFound: If primary_key_name not in a record dictionary + primary_key.NotFound: If primary_key_name does not match dataset primary key + primary_key.NotFound: If primary_key_name not in a record dictionary """ if primary_key_name is None: primary_key_name = dataset.key_attribute_names[0] if primary_key_name not in dataset.key_attribute_names: - raise PrimaryKeyNotFound( + raise primary_key.NotFound( f"Primary key: {primary_key_name} is not in dataset key attribute names: {dataset.key_attribute_names}" ) updates = ( diff --git a/tamr_client/primary_key.py b/tamr_client/primary_key.py new file mode 100644 index 00000000..979028f8 --- /dev/null +++ b/tamr_client/primary_key.py @@ -0,0 +1,10 @@ +class Ambiguous(Exception): + """Raised when referencing a primary key by name that matches multiple possible targets.""" + + pass + + +class NotFound(Exception): + """Raised when referencing a primary key by name that does not exist.""" + + pass diff --git a/tests/tamr_client/dataset/test_dataframe.py b/tests/tamr_client/dataset/test_dataframe.py index 62a5216d..0b77cc67 100644 --- a/tests/tamr_client/dataset/test_dataframe.py +++ b/tests/tamr_client/dataset/test_dataframe.py @@ -42,7 +42,7 @@ def test_upsert_primary_key_not_found(): df = pd.DataFrame(_records_json) - with pytest.raises(tc.record.PrimaryKeyNotFound): + with pytest.raises(tc.primary_key.NotFound): tc.dataframe.upsert(s, dataset, df, primary_key_name="wrong_primary_key") @@ -113,7 +113,7 @@ def test_upsert_index_column_name_collision(): # create column in `df` with same name as index and matching "primary_key" df.insert(0, df.index.name, df.index) - with pytest.raises(tc.AmbiguousPrimaryKey): + with pytest.raises(tc.primary_key.Ambiguous): tc.dataframe.upsert(s, dataset, df, primary_key_name="primary_key") diff --git a/tests/tamr_client/dataset/test_record.py b/tests/tamr_client/dataset/test_record.py index 5053bc08..3043ecb6 100644 --- a/tests/tamr_client/dataset/test_record.py +++ b/tests/tamr_client/dataset/test_record.py @@ -64,7 +64,7 @@ def test_upsert_primary_key_not_found(): s = utils.session() dataset = utils.dataset() - with pytest.raises(tc.record.PrimaryKeyNotFound): + with pytest.raises(tc.primary_key.NotFound): tc.record.upsert( s, dataset, _records_json, primary_key_name="wrong_primary_key" ) @@ -125,7 +125,7 @@ def test_delete_primary_key_not_found(): s = utils.session() dataset = utils.dataset() - with pytest.raises(tc.record.PrimaryKeyNotFound): + with pytest.raises(tc.primary_key.NotFound): tc.record.delete( s, dataset, _records_json, primary_key_name="wrong_primary_key" ) From 67e45830015bca9a7a65cbc4e726821f3a8b16fc Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 2 Jul 2020 14:44:37 -0400 Subject: [PATCH 474/632] Create a base class for Tamr Client exceptions Allows users to catch any TC exception via tc.TamrClientException . Also, clean up imports in tamr_client/__init__.py --- CHANGELOG.md | 1 + tamr_client/__init__.py | 52 ++++++++++------------------- tamr_client/attribute/_attribute.py | 7 ++-- tamr_client/dataset/_dataset.py | 3 +- tamr_client/dataset/unified.py | 3 +- tamr_client/exception.py | 4 +++ tamr_client/operation.py | 3 +- tamr_client/primary_key.py | 7 ++-- tamr_client/project.py | 3 +- 9 files changed, 40 insertions(+), 43 deletions(-) create mode 100644 tamr_client/exception.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d3e00aa5..617210b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Mastering projects via `tc.mastering.project` - Support for streaming records from a dataset via `tc.record.stream` - Support for operations via `tc.operations` + - `tc.TamrClientException` as a base class for all `tamr_client` exceptions **BUG FIXES** - `from_geo_features` now returns information on the operation. diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 130524a9..16080a79 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -1,10 +1,3 @@ -# BETA check -############ - -from tamr_client import _beta - -_beta.check() - # Logging ######### @@ -13,6 +6,13 @@ # https://docs.python-guide.org/writing/logging/#logging-in-a-library logging.getLogger(__name__).addHandler(logging.NullHandler()) +# BETA check +############ + +from tamr_client import _beta + +_beta.check() + # types ####### @@ -32,34 +32,18 @@ UsernamePasswordAuth, ) -# functions -########### - -# utilities -from tamr_client import response - -# instance -from tamr_client import instance - -# session -from tamr_client import session +# functionality +############### -from tamr_client import primary_key - -# datasets -from tamr_client import dataset - -# records -from tamr_client.dataset import record - -# dataframe -from tamr_client.dataset import dataframe - -# attributes from tamr_client import attribute - +from tamr_client import dataset +from tamr_client import instance from tamr_client import mastering -from tamr_client import project - -# operations from tamr_client import operation +from tamr_client import primary_key +from tamr_client import project +from tamr_client import response +from tamr_client import session +from tamr_client.dataset import dataframe +from tamr_client.dataset import record +from tamr_client.exception import TamrClientException diff --git a/tamr_client/attribute/_attribute.py b/tamr_client/attribute/_attribute.py index 54ec90b0..95538395 100644 --- a/tamr_client/attribute/_attribute.py +++ b/tamr_client/attribute/_attribute.py @@ -8,6 +8,7 @@ from tamr_client import response from tamr_client._types import Attribute, AttributeType, Dataset, JsonDict, Session, URL from tamr_client.attribute import type as attribute_type +from tamr_client.exception import TamrClientException _RESERVED_NAMES = frozenset( @@ -29,13 +30,13 @@ ) -class AlreadyExists(Exception): +class AlreadyExists(TamrClientException): """Raised when trying to create an attribute that already exists on the server""" pass -class NotFound(Exception): +class NotFound(TamrClientException): """Raised when referencing (e.g. updating or deleting) an attribute that does not exist on the server. """ @@ -43,7 +44,7 @@ class NotFound(Exception): pass -class ReservedName(Exception): +class ReservedName(TamrClientException): """Raised when attempting to create an attribute with a reserved name""" pass diff --git a/tamr_client/dataset/_dataset.py b/tamr_client/dataset/_dataset.py index 55930b36..390d7f0d 100644 --- a/tamr_client/dataset/_dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -5,9 +5,10 @@ from tamr_client import response from tamr_client._types import Dataset, Instance, JsonDict, Session, URL +from tamr_client.exception import TamrClientException -class NotFound(Exception): +class NotFound(TamrClientException): """Raised when referencing (e.g. updating or deleting) a dataset that does not exist on the server. """ diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index a4428053..0d19eeec 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -13,9 +13,10 @@ UnifiedDataset, URL, ) +from tamr_client.exception import TamrClientException -class NotFound(Exception): +class NotFound(TamrClientException): """Raised when referencing (e.g. updating or deleting) a unified dataset that does not exist on the server. """ diff --git a/tamr_client/exception.py b/tamr_client/exception.py new file mode 100644 index 00000000..d1e5075b --- /dev/null +++ b/tamr_client/exception.py @@ -0,0 +1,4 @@ +class TamrClientException(Exception): + """Base class for all Tamr Client exceptions""" + + pass diff --git a/tamr_client/operation.py b/tamr_client/operation.py index ac767c37..7f0a04be 100644 --- a/tamr_client/operation.py +++ b/tamr_client/operation.py @@ -9,9 +9,10 @@ from tamr_client import response from tamr_client._types import Instance, JsonDict, Operation, Session, URL +from tamr_client.exception import TamrClientException -class NotFound(Exception): +class NotFound(TamrClientException): """Raised when referencing an operation that does not exist on the server. """ diff --git a/tamr_client/primary_key.py b/tamr_client/primary_key.py index 979028f8..840f443a 100644 --- a/tamr_client/primary_key.py +++ b/tamr_client/primary_key.py @@ -1,10 +1,13 @@ -class Ambiguous(Exception): +from tamr_client.exception import TamrClientException + + +class Ambiguous(TamrClientException): """Raised when referencing a primary key by name that matches multiple possible targets.""" pass -class NotFound(Exception): +class NotFound(TamrClientException): """Raised when referencing a primary key by name that does not exist.""" pass diff --git a/tamr_client/project.py b/tamr_client/project.py index eedd787e..66549680 100644 --- a/tamr_client/project.py +++ b/tamr_client/project.py @@ -1,9 +1,10 @@ from tamr_client import response from tamr_client._types import Instance, JsonDict, Project, Session, URL +from tamr_client.exception import TamrClientException from tamr_client.mastering import project as mastering_project -class NotFound(Exception): +class NotFound(TamrClientException): """Raised when referencing (e.g. updating or deleting) a project that does not exist on the server.""" From 4b9102d4bec4aed8dc74cf7620ae126230cb1e81 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 2 Jul 2020 17:09:20 -0400 Subject: [PATCH 475/632] Bump version in preparation for release --- CHANGELOG.md | 4 +++- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 617210b8..5ac3a536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ -## 0.12.0-dev +## 0.13.0-dev + +## 0.12.0 **BETA** Important: Do not use BETA features for production workflows. - [#372](https://github.com/Datatamer/tamr-client/issues/372) TC:Design for unified datasets diff --git a/pyproject.toml b/pyproject.toml index 00b6d5b3..7b6845e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "tamr-unify-client" -version = "0.12.0-dev" +version = "0.13.0-dev" description = "Python Client for the Tamr API" license = "Apache-2.0" authors = ["Pedro Cattori "] From 0db66bf8483cfa87c265d57f4d6aea76692dd936 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 2 Jul 2020 17:35:58 -0400 Subject: [PATCH 476/632] Fix errors related to TUC pandas usage pandas is now a dev dependency, bu tuc/dataset/resource.py imports pandas. It does so merely for type annotations, so the same treatment is applied as for tc/dataset/dataframe.py . --- tamr_unify_client/dataset/resource.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index 59340028..ee3be3b4 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -1,6 +1,7 @@ from copy import deepcopy +import os +from typing import TYPE_CHECKING -import pandas as pd import simplejson as json from tamr_unify_client.attribute.collection import AttributeCollection @@ -11,6 +12,10 @@ from tamr_unify_client.dataset.usage import DatasetUsage from tamr_unify_client.operation import Operation +BUILDING_DOCS = os.environ.get("TAMR_CLIENT_DOCS") == "1" +if TYPE_CHECKING or BUILDING_DOCS: + import pandas as pd + class Dataset(BaseResource): """A Tamr dataset.""" @@ -86,7 +91,7 @@ def _update_records(self, updates, **json_args): ) def upsert_from_dataframe( - self, df: pd.DataFrame, *, primary_key_name: str, ignore_nan: bool = True + self, df: "pd.DataFrame", *, primary_key_name: str, ignore_nan: bool = True ) -> dict: """Upserts a record for each row of `df` with attributes for each column in `df`. From 78d35f35a3175cff48e88ed39834bd5ca3c428ae Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 8 Jul 2020 15:34:03 -0400 Subject: [PATCH 477/632] Add operation.from_resource_id function. --- tamr_client/operation.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tamr_client/operation.py b/tamr_client/operation.py index 7f0a04be..53eea816 100644 --- a/tamr_client/operation.py +++ b/tamr_client/operation.py @@ -68,6 +68,19 @@ def succeeded(operation: Operation) -> bool: return operation.status is not None and operation.status["state"] == "SUCCEEDED" +def from_resource_id( + session: Session, instance: Instance, resource_id: str +) -> Operation: + """Get operation by ID + + Args: + resource_id: The ID of the operation + """ + url = URL(instance=instance, path=f"operations/{resource_id}") + r = session.get(str(url), headers={"Accept": "application/json"}) + return _from_response(instance, r) + + def _from_response(instance: Instance, response: requests.Response) -> Operation: """ Handle idiosyncrasies in constructing Operations from Tamr responses. From 1b7248a7fab0d3241e513973a900faec0e023e01 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 8 Jul 2020 15:34:27 -0400 Subject: [PATCH 478/632] Add testing for operation.from_resource_id. --- tests/tamr_client/test_operation.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/tamr_client/test_operation.py b/tests/tamr_client/test_operation.py index 35d1ad32..c7786760 100644 --- a/tests/tamr_client/test_operation.py +++ b/tests/tamr_client/test_operation.py @@ -77,6 +77,24 @@ def test_operation_from_response_noop(): tc.operation.poll(s, op2w) +@responses.activate +def test_from_resource_id(): + s = utils.session() + instance = utils.instance() + url = tc.URL(path="operations/1") + + operation_json = utils.load_json("operation_succeeded.json") + responses.add(responses.GET, str(url), json=operation_json) + + resource_id = "1" + op = tc.operation.from_resource_id(s, instance, resource_id) + assert op.url == url + assert op.type == operation_json["type"] + assert op.description == operation_json["description"] + assert op.status == operation_json["status"] + assert tc.operation.succeeded(op) + + @responses.activate def test_operation_poll(): s = utils.session() From d83bc21ff5a7951df313febbd6cb17c3d76ec90f Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 8 Jul 2020 15:43:22 -0400 Subject: [PATCH 479/632] Update CHANGELOG and docs. --- CHANGELOG.md | 5 ++++- docs/beta/operation.rst | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ac3a536..a5a6613e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## 0.13.0-dev - + **BETA** + Important: Do not use BETA features for production workflows. + - Added function to get operation from resource ID + ## 0.12.0 **BETA** Important: Do not use BETA features for production workflows. diff --git a/docs/beta/operation.rst b/docs/beta/operation.rst index 54484a9d..f552caca 100644 --- a/docs/beta/operation.rst +++ b/docs/beta/operation.rst @@ -5,4 +5,5 @@ Operation .. autofunction:: tamr_client.operation.poll .. autofunction:: tamr_client.operation.wait -.. autofunction:: tamr_client.operation.succeeded \ No newline at end of file +.. autofunction:: tamr_client.operation.succeeded +.. autofunction:: tamr_client.operation.from_resource_id \ No newline at end of file From daf4c32c77dfc6ad62b19e2c3cde3168c01cd335 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 8 Jul 2020 16:10:22 -0400 Subject: [PATCH 480/632] Add tests for TUC operation from_resource_id method. --- tests/unit/test_operation.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/unit/test_operation.py b/tests/unit/test_operation.py index 96398dd3..f984fa95 100644 --- a/tests/unit/test_operation.py +++ b/tests/unit/test_operation.py @@ -59,6 +59,16 @@ def test_operation_from_json(client): assert op1.succeeded +@responses.activate +def test_operation_from_resource_id(client): + responses.add(responses.GET, full_url(client, "operations/1"), json=op_1_json) + + op1 = Operation.from_resource_id(client, "1") + + assert op1.resource_id == "1" + assert op1.succeeded + + @responses.activate def test_operation_from_response(client): responses.add(responses.GET, full_url(client, "operations/1"), json=op_1_json) From ba28eca13a820c9ad216a0339b80433d9df54a73 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 8 Jul 2020 16:10:49 -0400 Subject: [PATCH 481/632] Add operation method from_resource_id to TUC. --- tamr_unify_client/operation.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tamr_unify_client/operation.py b/tamr_unify_client/operation.py index c38e2bc8..4e9e3405 100644 --- a/tamr_unify_client/operation.py +++ b/tamr_unify_client/operation.py @@ -18,6 +18,21 @@ class Operation(BaseResource): def from_json(cls, client, resource_json, api_path=None): return super().from_data(client, resource_json, api_path) + @classmethod + def from_resource_id(cls, client, resource_id): + """Get an operation by resource ID. + + :param client: Delegate underlying API calls to this client. + :type client: :class:`~tamr_unify_client.Client` + :param resource_id: The ID of the operation + :type resource_id: str + :returns: The specified operation + :rtype: :class:`~tamr_unify_client.operation.Operation` + """ + url = f"operations/{resource_id}" + response = client.get(url, headers={"Accept": "application/json"}).successful() + return Operation.from_response(client, response) + @classmethod def from_response(cls, client, response): """ From 71d6de18c9f4a8d9c6460a90fe58f33281d35eb9 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 8 Jul 2020 16:14:15 -0400 Subject: [PATCH 482/632] Update CHANGELOG --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5a6613e..efb33f4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ Important: Do not use BETA features for production workflows. - Added function to get operation from resource ID + **NEW FEATURES** + - [#383](https://github.com/Datatamer/tamr-client/issues/383) Now able to create an Operation from Job resource id + ## 0.12.0 **BETA** Important: Do not use BETA features for production workflows. From 6e050dacab21f0256dc63b23d3e664503c46b3ce Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 8 Jul 2020 16:59:54 -0400 Subject: [PATCH 483/632] Remove unnecessary headers argument from client.get calls. --- tamr_client/operation.py | 2 +- tamr_unify_client/operation.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tamr_client/operation.py b/tamr_client/operation.py index 53eea816..61a9e3af 100644 --- a/tamr_client/operation.py +++ b/tamr_client/operation.py @@ -77,7 +77,7 @@ def from_resource_id( resource_id: The ID of the operation """ url = URL(instance=instance, path=f"operations/{resource_id}") - r = session.get(str(url), headers={"Accept": "application/json"}) + r = session.get(str(url)) return _from_response(instance, r) diff --git a/tamr_unify_client/operation.py b/tamr_unify_client/operation.py index 4e9e3405..9f730b14 100644 --- a/tamr_unify_client/operation.py +++ b/tamr_unify_client/operation.py @@ -30,7 +30,7 @@ def from_resource_id(cls, client, resource_id): :rtype: :class:`~tamr_unify_client.operation.Operation` """ url = f"operations/{resource_id}" - response = client.get(url, headers={"Accept": "application/json"}).successful() + response = client.get(url).successful() return Operation.from_response(client, response) @classmethod From 01d2d027d48c2bc8a42b82370efa67c2d8e6766f Mon Sep 17 00:00:00 2001 From: Keziah Katz Date: Fri, 10 Jul 2020 14:50:25 -0400 Subject: [PATCH 484/632] inital tx add --- tamr_client/__init__.py | 5 +- tamr_client/_types/__init__.py | 3 +- tamr_client/_types/transformations.py | 17 +++ tamr_client/response.py | 2 +- tamr_client/transformations.py | 125 ++++++++++++++++++++ tests/tamr_client/data/transformations.json | 21 ++++ tests/tamr_client/test_transformations.py | 31 +++++ tests/temp_test.py | 23 ++++ 8 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 tamr_client/_types/transformations.py create mode 100644 tamr_client/transformations.py create mode 100644 tests/tamr_client/data/transformations.json create mode 100644 tests/tamr_client/test_transformations.py create mode 100644 tests/temp_test.py diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 16080a79..212f8bbc 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -27,9 +27,11 @@ Project, Session, SubAttribute, + Transformations, + InputTransformation, UnifiedDataset, URL, - UsernamePasswordAuth, + UsernamePasswordAuth ) # functionality @@ -44,6 +46,7 @@ from tamr_client import project from tamr_client import response from tamr_client import session +from tamr_client import transformations from tamr_client.dataset import dataframe from tamr_client.dataset import record from tamr_client.exception import TamrClientException diff --git a/tamr_client/_types/__init__.py b/tamr_client/_types/__init__.py index c165992a..6b37f2d7 100644 --- a/tamr_client/_types/__init__.py +++ b/tamr_client/_types/__init__.py @@ -13,7 +13,7 @@ PrimitiveType, Record, STRING, - SubAttribute, + SubAttribute ) from tamr_client._types.auth import UsernamePasswordAuth from tamr_client._types.dataset import AnyDataset, Dataset, UnifiedDataset @@ -23,3 +23,4 @@ from tamr_client._types.project import MasteringProject, Project from tamr_client._types.session import Session from tamr_client._types.url import URL +from tamr_client._types.transformations import Transformations, InputTransformation diff --git a/tamr_client/_types/transformations.py b/tamr_client/_types/transformations.py new file mode 100644 index 00000000..70060765 --- /dev/null +++ b/tamr_client/_types/transformations.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass, field +from typing import List + +from tamr_client import Dataset + + +@dataclass(frozen=True) +class InputTransformation: + transformation: str + datasets: List[Dataset] = field(default_factory=list) + + +@dataclass(frozen=True) +class Transformations: + input_scope: List[InputTransformation] = field(default_factory=list) + unified_scope: List[str] = field(default_factory=list) + diff --git a/tamr_client/response.py b/tamr_client/response.py index dad88aad..afc0d4fe 100644 --- a/tamr_client/response.py +++ b/tamr_client/response.py @@ -27,7 +27,7 @@ def successful(response: requests.Response) -> requests.Response: logger.error( f"Encountered HTTP error code {r.status_code}. Response body: {r.text}" ) - raise e + raise requests.HTTPError(e, f"Caused by: {response.content}") return response diff --git a/tamr_client/transformations.py b/tamr_client/transformations.py new file mode 100644 index 00000000..c6a973ce --- /dev/null +++ b/tamr_client/transformations.py @@ -0,0 +1,125 @@ +from tamr_client import response, dataset +from tamr_client._types import Instance, JsonDict, Project, Session +from tamr_client._types.transformations import Transformations, InputTransformation + + +def _input_transformation_from_json(session: Session, instance: Instance, data: JsonDict) -> InputTransformation: + """Make input transformation from JSON data (deserialize) + + Args: + instance: Tamr instance containing this transformation + data: Input scoped transformation JSON data from Tamr server + """ + dataset_resource_ids = [d["datasetId"].split("/")[-1] for d in data["datasets"]] + datasets = [ + dataset.from_resource_id(session, instance, d_id) + for d_id in dataset_resource_ids + ] + return InputTransformation( + transformation=data["transformation"], + datasets=datasets + ) + + +def _transformations_from_json(session: Session, instance: Instance, data: JsonDict) -> Transformations: + """Make transformations from JSON data (deserialize) + + Args: + instance: Tamr instance containing this transformation + data: Transformation JSON data from Tamr server + """ + return Transformations( + unified_scope=data["unified"], + input_scope=[ + _input_transformation_from_json(session, instance, tx) + for tx in data["parameterized"] + ] + ) + + +def _input_transformation_to_json(tx: InputTransformation) -> JsonDict: + """Convert input transformations to JSON data (serialize) + + Args: + tx: Input transformation to convert + """ + # datasetId omitted, only one of "datasetId" or "relativeDatasetId" is required + dataset_json = [{ + "name": d.name, + "relativeDatasetId": d.url.path + } + for d in tx.datasets + ] + + return { + "datasets": dataset_json, + "transformation": tx.transformation + } + + +def _transformations_to_json(tx: Transformations) -> JsonDict: + """Convert transformations to JSON data (serialize) + + Args: + tx: Transformations to convert + """ + return { + "parameterized": [_input_transformation_to_json(t) for t in tx.input_scope], + "unified": tx.unified_scope + } + + +def get_all(session: Session, project: Project) -> Transformations: + """Get the transformations of a Project + + Args: + project: Project containing transformations + + Raises: + requests.HTTPError: If any HTTP error is encountered. + + Example: + >>> import tamr_client as tc + >>> session = tc.session.from_auth('username', 'password') + >>> instance = tc.instance.Instance(host="localhost", port=9100) + >>> project1 = tc.project.from_resource_id(session, instance, id='1') + >>> print(tc.transformations.get_all(session, project1)) + """ + r = session.get( + f"{project.url}/transformations", + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + response.successful(r) + return _transformations_from_json(session, project.url.instance, r.json()) + + +def replace_all(session: Session, project: Project, tx: Transformations) -> response: + """Replaces the transformations of a Project + + Args: + project: Project to place transformations within + tx: Transformations to put into project + + Raises: + requests.HTTPError: If any HTTP error is encountered. + + Example: + >>> import tamr_client as tc + >>> session = tc.session.from_auth('username', 'password') + >>> instance = tc.instance.Instance(host="localhost", port=9100) + >>> project1 = tc.project.from_resource_id(session, instance, id='1') + >>> dataset3 = tc.dataset.from_resource_id(session, instance, id='3') + >>> new_input_tx = tc.InputTransformation("SELECT *, upper(name) as name;", [dataset3]) + >>> all_tx = tc.Transformations( + ... input_scope=[new_input_tx], unified_scope=["SELECT *, 1 as one;"] + ... ) + >>> tc.transformations.replace_all(session, project1, all_tx) + """ + body = _transformations_to_json(tx) + r = session.put( + f"{project.url}/transformations", + json=body, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + response.successful(r) + return r \ No newline at end of file diff --git a/tests/tamr_client/data/transformations.json b/tests/tamr_client/data/transformations.json new file mode 100644 index 00000000..ccf759a8 --- /dev/null +++ b/tests/tamr_client/data/transformations.json @@ -0,0 +1,21 @@ +{ + "parameterized": [ + { + "datasets": [], + "transformation": "SELECT *, 1 as one;" + }, + { + "datasets": [ + { + "name": "dataset 1 name", + "datasetId": "unify://unified-data/v1/datasets/1", + "relativeDatasetId": "datasets/1" + } + ], + "transformation": "SELECT *, 2 as two;" + } + ], + "unified": [ + "//Comment\nSELECT *;" + ] +} \ No newline at end of file diff --git a/tests/tamr_client/test_transformations.py b/tests/tamr_client/test_transformations.py new file mode 100644 index 00000000..80e110e5 --- /dev/null +++ b/tests/tamr_client/test_transformations.py @@ -0,0 +1,31 @@ +import pytest +import responses + +import tamr_client as tc +from tests.tamr_client import utils + + +@responses.activate +def test_get_all(): + s = utils.session() + instance = utils.instance() + + project_json = utils.load_json("mastering_project.json") + project_url = tc.URL(path="projects/1") + responses.add(responses.GET, str(project_url), json=project_json) + + tx_json = utils.load_json("transformations.json") + tx_url = tc.URL(path="projects/1/transformations") + responses.add(responses.GET, str(tx_url), json=tx_json) + + dataset_json = utils.load_json("dataset.json") + dataset_url = tc.URL(path="datasets/1") + responses.add(responses.GET, str(dataset_url), json=dataset_json) + + project = tc.project.from_resource_id(s, instance, "1") + transforms = tc.transformations.get_all(s, project) + + assert isinstance(transforms, tc.Transformations) + + assert len(transforms.input_scope) == 2 + assert len(transforms.unified_scope) == 1 \ No newline at end of file diff --git a/tests/temp_test.py b/tests/temp_test.py new file mode 100644 index 00000000..f6c57a4a --- /dev/null +++ b/tests/temp_test.py @@ -0,0 +1,23 @@ + +import os + +os.environ.setdefault("TAMR_CLIENT_BETA", "1") + +import tamr_client as tc + +auth = tc.UsernamePasswordAuth('admin', 'dt') +session = tc.session.from_auth(auth) +instance = tc.instance.Instance(host="procurify-demo.tamrdev.com", port=9100) + +project = tc.project.from_resource_id(session, instance, "2") + +tx = tc.project.transformations(session, project) +print(tx) + +dataset = tc.dataset.from_resource_id(session, instance, '3') +new_input_tx = tc.InputTransformation("SELECT *, upper(name) as name;", [dataset]) +all_tx = tc.Transformations(input_scope=[new_input_tx], unified_scope=["SELECT *, 1 as one;"]) +tc.project.replace_transformations(session, project, all_tx) + + + From 66336f070ffd035a763bf03467965beb56f9a0e4 Mon Sep 17 00:00:00 2001 From: Keziah Katz Date: Fri, 10 Jul 2020 17:43:38 -0400 Subject: [PATCH 485/632] finish testing --- stubs/simplejson/errors.pyi | 4 ++++ tests/temp_test.py | 23 ----------------------- 2 files changed, 4 insertions(+), 23 deletions(-) create mode 100644 stubs/simplejson/errors.pyi delete mode 100644 tests/temp_test.py diff --git a/stubs/simplejson/errors.pyi b/stubs/simplejson/errors.pyi new file mode 100644 index 00000000..28d34d77 --- /dev/null +++ b/stubs/simplejson/errors.pyi @@ -0,0 +1,4 @@ + + +class JSONDecodeError(Exception): + pass \ No newline at end of file diff --git a/tests/temp_test.py b/tests/temp_test.py deleted file mode 100644 index f6c57a4a..00000000 --- a/tests/temp_test.py +++ /dev/null @@ -1,23 +0,0 @@ - -import os - -os.environ.setdefault("TAMR_CLIENT_BETA", "1") - -import tamr_client as tc - -auth = tc.UsernamePasswordAuth('admin', 'dt') -session = tc.session.from_auth(auth) -instance = tc.instance.Instance(host="procurify-demo.tamrdev.com", port=9100) - -project = tc.project.from_resource_id(session, instance, "2") - -tx = tc.project.transformations(session, project) -print(tx) - -dataset = tc.dataset.from_resource_id(session, instance, '3') -new_input_tx = tc.InputTransformation("SELECT *, upper(name) as name;", [dataset]) -all_tx = tc.Transformations(input_scope=[new_input_tx], unified_scope=["SELECT *, 1 as one;"]) -tc.project.replace_transformations(session, project, all_tx) - - - From 5dd225b8460ce80df2a86ce522a529b05a41c30b Mon Sep 17 00:00:00 2001 From: Keziah Katz Date: Fri, 10 Jul 2020 17:43:46 -0400 Subject: [PATCH 486/632] finish testing --- stubs/simplejson/errors.pyi | 4 +- tamr_client/__init__.py | 4 +- tamr_client/_types/__init__.py | 4 +- tamr_client/_types/transformations.py | 3 +- tamr_client/response.py | 2 +- tamr_client/transformations.py | 74 +++++++++----- tests/tamr_client/test_transformations.py | 116 +++++++++++++++++++++- 7 files changed, 168 insertions(+), 39 deletions(-) diff --git a/stubs/simplejson/errors.pyi b/stubs/simplejson/errors.pyi index 28d34d77..25f93d23 100644 --- a/stubs/simplejson/errors.pyi +++ b/stubs/simplejson/errors.pyi @@ -1,4 +1,2 @@ - - class JSONDecodeError(Exception): - pass \ No newline at end of file + pass diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 212f8bbc..281123d1 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -21,6 +21,7 @@ Attribute, AttributeType, Dataset, + InputTransformation, Instance, MasteringProject, Operation, @@ -28,10 +29,9 @@ Session, SubAttribute, Transformations, - InputTransformation, UnifiedDataset, URL, - UsernamePasswordAuth + UsernamePasswordAuth, ) # functionality diff --git a/tamr_client/_types/__init__.py b/tamr_client/_types/__init__.py index 6b37f2d7..66e1b909 100644 --- a/tamr_client/_types/__init__.py +++ b/tamr_client/_types/__init__.py @@ -13,7 +13,7 @@ PrimitiveType, Record, STRING, - SubAttribute + SubAttribute, ) from tamr_client._types.auth import UsernamePasswordAuth from tamr_client._types.dataset import AnyDataset, Dataset, UnifiedDataset @@ -22,5 +22,5 @@ from tamr_client._types.operation import Operation from tamr_client._types.project import MasteringProject, Project from tamr_client._types.session import Session +from tamr_client._types.transformations import InputTransformation, Transformations from tamr_client._types.url import URL -from tamr_client._types.transformations import Transformations, InputTransformation diff --git a/tamr_client/_types/transformations.py b/tamr_client/_types/transformations.py index 70060765..e35ce40f 100644 --- a/tamr_client/_types/transformations.py +++ b/tamr_client/_types/transformations.py @@ -1,7 +1,7 @@ from dataclasses import dataclass, field from typing import List -from tamr_client import Dataset +from tamr_client._types import Dataset @dataclass(frozen=True) @@ -14,4 +14,3 @@ class InputTransformation: class Transformations: input_scope: List[InputTransformation] = field(default_factory=list) unified_scope: List[str] = field(default_factory=list) - diff --git a/tamr_client/response.py b/tamr_client/response.py index afc0d4fe..4391efd3 100644 --- a/tamr_client/response.py +++ b/tamr_client/response.py @@ -27,7 +27,7 @@ def successful(response: requests.Response) -> requests.Response: logger.error( f"Encountered HTTP error code {r.status_code}. Response body: {r.text}" ) - raise requests.HTTPError(e, f"Caused by: {response.content}") + raise requests.HTTPError(e) return response diff --git a/tamr_client/transformations.py b/tamr_client/transformations.py index c6a973ce..849b4ed5 100644 --- a/tamr_client/transformations.py +++ b/tamr_client/transformations.py @@ -1,9 +1,28 @@ -from tamr_client import response, dataset +import requests +from simplejson.errors import JSONDecodeError + +from tamr_client import dataset, response from tamr_client._types import Instance, JsonDict, Project, Session -from tamr_client._types.transformations import Transformations, InputTransformation +from tamr_client._types.transformations import InputTransformation, Transformations +from tamr_client.exception import TamrClientException + + +class InvalidInputDataset(TamrClientException): + """Raised when a Dataset within a InputTransformation + is not an input dataset of the target project.""" + + pass + + +class LintingError(TamrClientException): + """Raised when there are linting errors within Transformations.""" + pass -def _input_transformation_from_json(session: Session, instance: Instance, data: JsonDict) -> InputTransformation: + +def _input_transformation_from_json( + session: Session, instance: Instance, data: JsonDict +) -> InputTransformation: """Make input transformation from JSON data (deserialize) Args: @@ -15,13 +34,10 @@ def _input_transformation_from_json(session: Session, instance: Instance, data: dataset.from_resource_id(session, instance, d_id) for d_id in dataset_resource_ids ] - return InputTransformation( - transformation=data["transformation"], - datasets=datasets - ) + return InputTransformation(transformation=data["transformation"], datasets=datasets) -def _transformations_from_json(session: Session, instance: Instance, data: JsonDict) -> Transformations: +def _from_json(session: Session, instance: Instance, data: JsonDict) -> Transformations: """Make transformations from JSON data (deserialize) Args: @@ -33,7 +49,7 @@ def _transformations_from_json(session: Session, instance: Instance, data: JsonD input_scope=[ _input_transformation_from_json(session, instance, tx) for tx in data["parameterized"] - ] + ], ) @@ -44,20 +60,14 @@ def _input_transformation_to_json(tx: InputTransformation) -> JsonDict: tx: Input transformation to convert """ # datasetId omitted, only one of "datasetId" or "relativeDatasetId" is required - dataset_json = [{ - "name": d.name, - "relativeDatasetId": d.url.path - } - for d in tx.datasets - ] + dataset_json = [ + {"name": d.name, "relativeDatasetId": d.url.path} for d in tx.datasets + ] - return { - "datasets": dataset_json, - "transformation": tx.transformation - } + return {"datasets": dataset_json, "transformation": tx.transformation} -def _transformations_to_json(tx: Transformations) -> JsonDict: +def _to_json(tx: Transformations) -> JsonDict: """Convert transformations to JSON data (serialize) Args: @@ -65,7 +75,7 @@ def _transformations_to_json(tx: Transformations) -> JsonDict: """ return { "parameterized": [_input_transformation_to_json(t) for t in tx.input_scope], - "unified": tx.unified_scope + "unified": tx.unified_scope, } @@ -90,10 +100,12 @@ def get_all(session: Session, project: Project) -> Transformations: headers={"Content-Type": "application/json", "Accept": "application/json"}, ) response.successful(r) - return _transformations_from_json(session, project.url.instance, r.json()) + return _from_json(session, project.url.instance, r.json()) -def replace_all(session: Session, project: Project, tx: Transformations) -> response: +def replace_all( + session: Session, project: Project, tx: Transformations +) -> requests.Response: """Replaces the transformations of a Project Args: @@ -115,11 +127,23 @@ def replace_all(session: Session, project: Project, tx: Transformations) -> resp ... ) >>> tc.transformations.replace_all(session, project1, all_tx) """ - body = _transformations_to_json(tx) + body = _to_json(tx) r = session.put( f"{project.url}/transformations", json=body, headers={"Content-Type": "application/json", "Accept": "application/json"}, ) + if r.status_code == 400: + try: + r_json = r.json() + if r_json["class"] == "java.lang.IllegalArgumentException": + raise InvalidInputDataset(r_json["message"], str(tx)) + if r_json["class"] == "javax.ws.rs.BadRequestException": + raise LintingError(r_json["message"], str(tx)) + except JSONDecodeError: + # Any status code 400 that doesn't have a valid json body + # be caught with the generic success check below + pass + response.successful(r) - return r \ No newline at end of file + return r diff --git a/tests/tamr_client/test_transformations.py b/tests/tamr_client/test_transformations.py index 80e110e5..7585db3e 100644 --- a/tests/tamr_client/test_transformations.py +++ b/tests/tamr_client/test_transformations.py @@ -1,4 +1,5 @@ import pytest +from requests import HTTPError import responses import tamr_client as tc @@ -7,9 +8,7 @@ @responses.activate def test_get_all(): - s = utils.session() - instance = utils.instance() - + # setup project_json = utils.load_json("mastering_project.json") project_url = tc.URL(path="projects/1") responses.add(responses.GET, str(project_url), json=project_json) @@ -22,10 +21,119 @@ def test_get_all(): dataset_url = tc.URL(path="datasets/1") responses.add(responses.GET, str(dataset_url), json=dataset_json) + # test + s = utils.session() + instance = utils.instance() project = tc.project.from_resource_id(s, instance, "1") + transforms = tc.transformations.get_all(s, project) assert isinstance(transforms, tc.Transformations) assert len(transforms.input_scope) == 2 - assert len(transforms.unified_scope) == 1 \ No newline at end of file + assert len(transforms.unified_scope) == 1 + + assert len(transforms.input_scope[0].datasets) == 0 + assert transforms.input_scope[0].transformation == "SELECT *, 1 as one;" + assert len(transforms.input_scope[1].datasets) == 1 + assert transforms.input_scope[1].datasets[0].name == "dataset 1 name" + assert transforms.input_scope[1].transformation == "SELECT *, 2 as two;" + + assert transforms.unified_scope[0] == "//Comment\nSELECT *;" + + +@responses.activate +def test_replace_all(): + # setup + project_json = utils.load_json("mastering_project.json") + project_url = tc.URL(path="projects/1") + responses.add(responses.GET, str(project_url), json=project_json) + + tx_json = utils.load_json("transformations.json") + tx_url = tc.URL(path="projects/1/transformations") + responses.add(responses.GET, str(tx_url), json=tx_json) + + dataset_json = utils.load_json("dataset.json") + dataset_url = tc.URL(path="datasets/1") + responses.add(responses.GET, str(dataset_url), json=dataset_json) + + # test + s = utils.session() + instance = utils.instance() + project = tc.project.from_resource_id(s, instance, "1") + + transforms = tc.transformations._from_json(s, instance, tx_json) + transforms.unified_scope.append("//extra TX") + transforms.input_scope.pop(1) + + responses.add( + responses.PUT, str(tx_url), json=tc.transformations._to_json(transforms) + ) + + r = tc.transformations.replace_all(s, project, transforms) + + posted_tx = tc.transformations._from_json(s, project.url.instance, r.json()) + + assert isinstance(posted_tx, tc.Transformations) + + assert len(posted_tx.input_scope) == 1 + assert len(posted_tx.unified_scope) == 2 + + assert len(posted_tx.input_scope[0].datasets) == 0 + assert posted_tx.input_scope[0].transformation == "SELECT *, 1 as one;" + + assert posted_tx.unified_scope[0] == "//Comment\nSELECT *;" + assert posted_tx.unified_scope[1] == "//extra TX" + + +@responses.activate +def test_replace_all_errors(): + # setup + project_json = utils.load_json("mastering_project.json") + project_url = tc.URL(path="projects/1") + responses.add(responses.GET, str(project_url), json=project_json) + + tx_json = utils.load_json("transformations.json") + tx_url = tc.URL(path="projects/1/transformations") + responses.add(responses.GET, str(tx_url), json=tx_json) + + dataset_json = utils.load_json("dataset.json") + dataset_url = tc.URL(path="datasets/1") + responses.add(responses.GET, str(dataset_url), json=dataset_json) + + # test + s = utils.session() + instance = utils.instance() + project = tc.project.from_resource_id(s, instance, "1") + + transforms = tc.transformations._from_json(s, instance, tx_json) + + bad_dataset_err = ( + '{"status": 400, "class": "java.lang.IllegalArgumentException", ' + '"message": "Could not find dataset d1 in this project", "stackTrace": ""}' + ) + lint_err = ( + '{"status": 400, "class": "javax.ws.rs.BadRequestException", ' + '"message": "------ ERROR ------\\n\\n mismatched input", "stackTrace": ""}' + ) + other_err = ( + '{"status": 400, "class": "anything.else", ' + '"message": "A bad thing happened.", "stackTrace": ""}' + ) + + responses.add(responses.PUT, str(tx_url), status=400, body=bad_dataset_err) + responses.add(responses.PUT, str(tx_url), status=400, body=lint_err) + responses.add(responses.PUT, str(tx_url), status=400, body=other_err) + responses.add(responses.PUT, str(tx_url), status=403) + + with pytest.raises(tc.transformations.InvalidInputDataset): + tc.transformations.replace_all(s, project, transforms) + + with pytest.raises(tc.transformations.LintingError): + tc.transformations.replace_all(s, project, transforms) + + with pytest.raises(HTTPError): + tc.transformations.replace_all(s, project, transforms) + + with pytest.raises(HTTPError): + tc.transformations.replace_all(s, project, transforms) From d42c5f6b8fe4f674f753ab79d62ce6f6fcad5c92 Mon Sep 17 00:00:00 2001 From: Keziah Katz Date: Fri, 10 Jul 2020 17:49:46 -0400 Subject: [PATCH 487/632] add docs --- docs/beta.md | 1 + docs/beta/transformations.rst | 13 +++++++++++++ tamr_client/transformations.py | 3 ++- 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 docs/beta/transformations.rst diff --git a/docs/beta.md b/docs/beta.md index 14551f42..42202be7 100644 --- a/docs/beta.md +++ b/docs/beta.md @@ -13,5 +13,6 @@ * [Operation](beta/operation) * [Primary Key](beta/primary_key) * [Project](beta/project) + * [Transformations](beta/transformations) * [Response](beta/response) * [Session](beta/session) diff --git a/docs/beta/transformations.rst b/docs/beta/transformations.rst new file mode 100644 index 00000000..7d74214d --- /dev/null +++ b/docs/beta/transformations.rst @@ -0,0 +1,13 @@ +Transformations +=============== + +.. autofunction:: tamr_client.transformations.get_all +.. autofunction:: tamr_client.transformations.replace_all + +Exceptions +---------- + +.. autoclass:: tamr_client.transformations.InvalidInputDataset + :no-inherited-members: +.. autoclass:: tamr_client.transformations.LintingError + :no-inherited-members: diff --git a/tamr_client/transformations.py b/tamr_client/transformations.py index 849b4ed5..551224e7 100644 --- a/tamr_client/transformations.py +++ b/tamr_client/transformations.py @@ -123,7 +123,8 @@ def replace_all( >>> dataset3 = tc.dataset.from_resource_id(session, instance, id='3') >>> new_input_tx = tc.InputTransformation("SELECT *, upper(name) as name;", [dataset3]) >>> all_tx = tc.Transformations( - ... input_scope=[new_input_tx], unified_scope=["SELECT *, 1 as one;"] + ... input_scope=[new_input_tx], + ... unified_scope=["SELECT *, 1 as one;"] ... ) >>> tc.transformations.replace_all(session, project1, all_tx) """ From 3afc674aede8dc0eed54fa0c6e23ec010592be41 Mon Sep 17 00:00:00 2001 From: Keziah Katz Date: Fri, 10 Jul 2020 17:57:04 -0400 Subject: [PATCH 488/632] rvert response.py --- tamr_client/response.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_client/response.py b/tamr_client/response.py index 4391efd3..dad88aad 100644 --- a/tamr_client/response.py +++ b/tamr_client/response.py @@ -27,7 +27,7 @@ def successful(response: requests.Response) -> requests.Response: logger.error( f"Encountered HTTP error code {r.status_code}. Response body: {r.text}" ) - raise requests.HTTPError(e) + raise e return response From 07a64577c61fa1f472e449906e38aa70f6f73ea5 Mon Sep 17 00:00:00 2001 From: Keziah Katz Date: Mon, 13 Jul 2020 15:31:33 -0400 Subject: [PATCH 489/632] pr comment fixes --- stubs/simplejson/errors.pyi | 2 -- tamr_client/transformations.py | 25 ++++++++++++----------- tests/tamr_client/test_transformations.py | 8 +++----- 3 files changed, 16 insertions(+), 19 deletions(-) delete mode 100644 stubs/simplejson/errors.pyi diff --git a/stubs/simplejson/errors.pyi b/stubs/simplejson/errors.pyi deleted file mode 100644 index 25f93d23..00000000 --- a/stubs/simplejson/errors.pyi +++ /dev/null @@ -1,2 +0,0 @@ -class JSONDecodeError(Exception): - pass diff --git a/tamr_client/transformations.py b/tamr_client/transformations.py index 551224e7..199e144a 100644 --- a/tamr_client/transformations.py +++ b/tamr_client/transformations.py @@ -1,9 +1,14 @@ import requests -from simplejson.errors import JSONDecodeError from tamr_client import dataset, response -from tamr_client._types import Instance, JsonDict, Project, Session -from tamr_client._types.transformations import InputTransformation, Transformations +from tamr_client._types import ( + InputTransformation, + Instance, + JsonDict, + Project, + Session, + Transformations, +) from tamr_client.exception import TamrClientException @@ -14,7 +19,7 @@ class InvalidInputDataset(TamrClientException): pass -class LintingError(TamrClientException): +class LintingFailed(TamrClientException): """Raised when there are linting errors within Transformations.""" pass @@ -95,10 +100,7 @@ def get_all(session: Session, project: Project) -> Transformations: >>> project1 = tc.project.from_resource_id(session, instance, id='1') >>> print(tc.transformations.get_all(session, project1)) """ - r = session.get( - f"{project.url}/transformations", - headers={"Content-Type": "application/json", "Accept": "application/json"}, - ) + r = session.get(f"{project.url}/transformations") response.successful(r) return _from_json(session, project.url.instance, r.json()) @@ -140,10 +142,9 @@ def replace_all( if r_json["class"] == "java.lang.IllegalArgumentException": raise InvalidInputDataset(r_json["message"], str(tx)) if r_json["class"] == "javax.ws.rs.BadRequestException": - raise LintingError(r_json["message"], str(tx)) - except JSONDecodeError: - # Any status code 400 that doesn't have a valid json body - # be caught with the generic success check below + raise LintingFailed(r_json["message"], str(tx)) + finally: + # Any failure in this try will be caught with the generic success check below pass response.successful(r) diff --git a/tests/tamr_client/test_transformations.py b/tests/tamr_client/test_transformations.py index 7585db3e..8c5db42c 100644 --- a/tests/tamr_client/test_transformations.py +++ b/tests/tamr_client/test_transformations.py @@ -28,8 +28,6 @@ def test_get_all(): transforms = tc.transformations.get_all(s, project) - assert isinstance(transforms, tc.Transformations) - assert len(transforms.input_scope) == 2 assert len(transforms.unified_scope) == 1 @@ -74,8 +72,6 @@ def test_replace_all(): posted_tx = tc.transformations._from_json(s, project.url.instance, r.json()) - assert isinstance(posted_tx, tc.Transformations) - assert len(posted_tx.input_scope) == 1 assert len(posted_tx.unified_scope) == 2 @@ -129,11 +125,13 @@ def test_replace_all_errors(): with pytest.raises(tc.transformations.InvalidInputDataset): tc.transformations.replace_all(s, project, transforms) - with pytest.raises(tc.transformations.LintingError): + with pytest.raises(tc.transformations.LintingFailed): tc.transformations.replace_all(s, project, transforms) + # 400 error with different class with pytest.raises(HTTPError): tc.transformations.replace_all(s, project, transforms) + # 403 error with pytest.raises(HTTPError): tc.transformations.replace_all(s, project, transforms) From e9efc33ce7439a57986c4264d7ebea642c4f1583 Mon Sep 17 00:00:00 2001 From: Keziah Katz Date: Mon, 13 Jul 2020 17:38:28 -0400 Subject: [PATCH 490/632] remove specific errors, update changelog --- CHANGELOG.md | 6 +++-- tamr_client/transformations.py | 30 +---------------------- tests/tamr_client/test_transformations.py | 29 +--------------------- 3 files changed, 6 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efb33f4b..983c6a40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,10 @@ ## 0.13.0-dev - **BETA** + **BETA** Important: Do not use BETA features for production workflows. - Added function to get operation from resource ID - + - [#421](https://github.com/Datatamer/tamr-client/pull/421) Added functions for getting and replacing the transformations of a projects via `tc.transformations.get_all()` and `tc.transformations.replace_all()` + - Added new dataclasses `Transformations` and `InputTransformations` to support these functions + **NEW FEATURES** - [#383](https://github.com/Datatamer/tamr-client/issues/383) Now able to create an Operation from Job resource id diff --git a/tamr_client/transformations.py b/tamr_client/transformations.py index 199e144a..14f2f0f2 100644 --- a/tamr_client/transformations.py +++ b/tamr_client/transformations.py @@ -9,20 +9,6 @@ Session, Transformations, ) -from tamr_client.exception import TamrClientException - - -class InvalidInputDataset(TamrClientException): - """Raised when a Dataset within a InputTransformation - is not an input dataset of the target project.""" - - pass - - -class LintingFailed(TamrClientException): - """Raised when there are linting errors within Transformations.""" - - pass def _input_transformation_from_json( @@ -131,21 +117,7 @@ def replace_all( >>> tc.transformations.replace_all(session, project1, all_tx) """ body = _to_json(tx) - r = session.put( - f"{project.url}/transformations", - json=body, - headers={"Content-Type": "application/json", "Accept": "application/json"}, - ) - if r.status_code == 400: - try: - r_json = r.json() - if r_json["class"] == "java.lang.IllegalArgumentException": - raise InvalidInputDataset(r_json["message"], str(tx)) - if r_json["class"] == "javax.ws.rs.BadRequestException": - raise LintingFailed(r_json["message"], str(tx)) - finally: - # Any failure in this try will be caught with the generic success check below - pass + r = session.put(f"{project.url}/transformations", json=body) response.successful(r) return r diff --git a/tests/tamr_client/test_transformations.py b/tests/tamr_client/test_transformations.py index 8c5db42c..85f8ec47 100644 --- a/tests/tamr_client/test_transformations.py +++ b/tests/tamr_client/test_transformations.py @@ -104,34 +104,7 @@ def test_replace_all_errors(): transforms = tc.transformations._from_json(s, instance, tx_json) - bad_dataset_err = ( - '{"status": 400, "class": "java.lang.IllegalArgumentException", ' - '"message": "Could not find dataset d1 in this project", "stackTrace": ""}' - ) - lint_err = ( - '{"status": 400, "class": "javax.ws.rs.BadRequestException", ' - '"message": "------ ERROR ------\\n\\n mismatched input", "stackTrace": ""}' - ) - other_err = ( - '{"status": 400, "class": "anything.else", ' - '"message": "A bad thing happened.", "stackTrace": ""}' - ) - - responses.add(responses.PUT, str(tx_url), status=400, body=bad_dataset_err) - responses.add(responses.PUT, str(tx_url), status=400, body=lint_err) - responses.add(responses.PUT, str(tx_url), status=400, body=other_err) - responses.add(responses.PUT, str(tx_url), status=403) - - with pytest.raises(tc.transformations.InvalidInputDataset): - tc.transformations.replace_all(s, project, transforms) - - with pytest.raises(tc.transformations.LintingFailed): - tc.transformations.replace_all(s, project, transforms) - - # 400 error with different class - with pytest.raises(HTTPError): - tc.transformations.replace_all(s, project, transforms) + responses.add(responses.PUT, str(tx_url), status=400) - # 403 error with pytest.raises(HTTPError): tc.transformations.replace_all(s, project, transforms) From 10527277338305cf13c210ab507544d2dbb9ddbb Mon Sep 17 00:00:00 2001 From: Keziah Katz Date: Tue, 14 Jul 2020 09:27:44 -0400 Subject: [PATCH 491/632] doc fix --- docs/beta/transformations.rst | 8 -------- tamr_client/transformations.py | 3 +-- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/docs/beta/transformations.rst b/docs/beta/transformations.rst index 7d74214d..7d433ab2 100644 --- a/docs/beta/transformations.rst +++ b/docs/beta/transformations.rst @@ -3,11 +3,3 @@ Transformations .. autofunction:: tamr_client.transformations.get_all .. autofunction:: tamr_client.transformations.replace_all - -Exceptions ----------- - -.. autoclass:: tamr_client.transformations.InvalidInputDataset - :no-inherited-members: -.. autoclass:: tamr_client.transformations.LintingError - :no-inherited-members: diff --git a/tamr_client/transformations.py b/tamr_client/transformations.py index 14f2f0f2..1734a2d8 100644 --- a/tamr_client/transformations.py +++ b/tamr_client/transformations.py @@ -119,5 +119,4 @@ def replace_all( body = _to_json(tx) r = session.put(f"{project.url}/transformations", json=body) - response.successful(r) - return r + return response.successful(r) From 7e24f6f9bb6fd165ddbe4a1fcf9ab7f2d846ca3d Mon Sep 17 00:00:00 2001 From: skalish Date: Tue, 28 Jul 2020 16:54:03 -0400 Subject: [PATCH 492/632] Fix missing return type-hint and duplicated Raises line in docstring. --- tamr_client/dataset/dataframe.py | 1 - tamr_client/operation.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/tamr_client/dataset/dataframe.py b/tamr_client/dataset/dataframe.py index d5023454..2274eeae 100644 --- a/tamr_client/dataset/dataframe.py +++ b/tamr_client/dataset/dataframe.py @@ -33,7 +33,6 @@ def upsert( JSON response body from the server Raises: - requests.HTTPError: If an HTTP error is encountered requests.HTTPError: If an HTTP error is encountered primary_key.NotFound: If `primary_key_name` is not a column in `df` or the index of `df` ValueError: If `primary_key_name` matches both a column in `df` and the index of `df` diff --git a/tamr_client/operation.py b/tamr_client/operation.py index 61a9e3af..44186511 100644 --- a/tamr_client/operation.py +++ b/tamr_client/operation.py @@ -141,7 +141,7 @@ def _from_url(session: Session, url: URL) -> Operation: return _from_json(url, data) -def _from_json(url: URL, data: JsonDict): +def _from_json(url: URL, data: JsonDict) -> Operation: """Make operation from JSON data (deserialize) Args: From e4205f258261f6e7b1640d748e01d8f77c0e3b29 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 30 Jul 2020 15:47:43 -0400 Subject: [PATCH 493/632] Create test utility for faking (mocking) Tamr --- tests/tamr_client/fake.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/tamr_client/fake.py diff --git a/tests/tamr_client/fake.py b/tests/tamr_client/fake.py new file mode 100644 index 00000000..777b99af --- /dev/null +++ b/tests/tamr_client/fake.py @@ -0,0 +1,33 @@ +from functools import wraps +from inspect import getfile +from json import load +from pathlib import Path + +import responses + +tests_tc_dir = (Path(__file__) / "..").resolve() +fake_json_dir = tests_tc_dir / "fake_json" + + +def _to_kwargs(fake): + req = fake["request"] + resp = fake["response"] + return {**req, **resp} + + +def json(test_fn): + test_file = Path(getfile(test_fn)) + + fakes_mod_path = fake_json_dir / test_file.relative_to(tests_tc_dir).with_suffix("") + fakes_test_path = (fakes_mod_path / test_fn.__name__).with_suffix(".json") + with open(fakes_test_path) as f: + fakes = load(f) + + @wraps(test_fn) + def wrapper(*args, **kwargs): + with responses.RequestsMock() as rsps: + for fake in fakes: + rsps.add(**_to_kwargs(fake)) + test_fn(*args, **kwargs) + + return wrapper From bbece5f5ec4cf0ad1d9143d698615d6e78c82651 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 30 Jul 2020 15:48:02 -0400 Subject: [PATCH 494/632] Add reminders to move fake session, instance, etc.. to fake utility --- tests/tamr_client/utils.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/tamr_client/utils.py b/tests/tamr_client/utils.py index 3966c768..fdfc8805 100644 --- a/tests/tamr_client/utils.py +++ b/tests/tamr_client/utils.py @@ -13,22 +13,26 @@ def load_json(path: Union[str, Path]): def session(): + # TODO move to fake.py auth = tc.UsernamePasswordAuth("username", "password") s = tc.session.from_auth(auth) return s def instance(): + # TODO move to fake.py return tc.Instance() def dataset(): + # TODO move to fake.py url = tc.URL(path="datasets/1") dataset = tc.Dataset(url, name="dataset.csv", key_attribute_names=("primary_key",)) return dataset def unified_dataset(): + # TODO move to fake.py url = tc.URL(path="projects/1/unifiedDataset") unified_dataset = tc.dataset.unified.UnifiedDataset( url, name="dataset.csv", key_attribute_names=("primary_key",) @@ -37,6 +41,7 @@ def unified_dataset(): def mastering_project(): + # TODO move to fake.py url = tc.URL(path="projects/1") mastering_project = tc.MasteringProject( url, name="Project 1", description="A Mastering Project" From 11fa860492f8b712f480c4b31484086cf2dd453b Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 30 Jul 2020 15:48:25 -0400 Subject: [PATCH 495/632] Convert test_project to use fake utility --- .../test_from_resource_id_mastering.json | 29 +++++++++++++++++++ .../test_from_resource_id_not_found.json | 11 +++++++ tests/tamr_client/test_project.py | 14 ++------- 3 files changed, 43 insertions(+), 11 deletions(-) create mode 100644 tests/tamr_client/fake_json/test_project/test_from_resource_id_mastering.json create mode 100644 tests/tamr_client/fake_json/test_project/test_from_resource_id_not_found.json diff --git a/tests/tamr_client/fake_json/test_project/test_from_resource_id_mastering.json b/tests/tamr_client/fake_json/test_project/test_from_resource_id_mastering.json new file mode 100644 index 00000000..e62dbb2e --- /dev/null +++ b/tests/tamr_client/fake_json/test_project/test_from_resource_id_mastering.json @@ -0,0 +1,29 @@ +[ + { + "request": { + "method": "GET", + "url": "http://localhost/api/versioned/v1/projects/1" + }, + "response": { + "json": { + "id": "unify://unified-data/v1/projects/1", + "name": "proj", + "description": "Mastering Project", + "type": "DEDUP", + "unifiedDatasetName": "proj_unified_dataset", + "created": { + "username": "admin", + "time": "2020-04-03T14:14:18.752Z", + "version": "18" + }, + "lastModified": { + "username": "admin", + "time": "2020-04-03T14:14:20.115Z", + "version": "19" + }, + "relativeId": "projects/1", + "externalId": "58bdbe72-3c08-427d-97bd-45b16d92c79c" + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/test_project/test_from_resource_id_not_found.json b/tests/tamr_client/fake_json/test_project/test_from_resource_id_not_found.json new file mode 100644 index 00000000..7a93f7ee --- /dev/null +++ b/tests/tamr_client/fake_json/test_project/test_from_resource_id_not_found.json @@ -0,0 +1,11 @@ +[ + { + "request": { + "method": "GET", + "url": "http://localhost/api/versioned/v1/projects/1" + }, + "response": { + "status": 404 + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/test_project.py b/tests/tamr_client/test_project.py index e6afe76e..3200cb98 100644 --- a/tests/tamr_client/test_project.py +++ b/tests/tamr_client/test_project.py @@ -1,32 +1,24 @@ import pytest -import responses import tamr_client as tc -from tests.tamr_client import utils +from tests.tamr_client import fake, utils -@responses.activate +@fake.json def test_from_resource_id_mastering(): s = utils.session() instance = utils.instance() - project_json = utils.load_json("mastering_project.json") - url = tc.URL(path="projects/1") - responses.add(responses.GET, str(url), json=project_json) - project = tc.project.from_resource_id(s, instance, "1") assert isinstance(project, tc.MasteringProject) assert project.name == "proj" assert project.description == "Mastering Project" -@responses.activate +@fake.json def test_from_resource_id_not_found(): s = utils.session() instance = utils.instance() - url = tc.URL(path="projects/1") - responses.add(responses.GET, str(url), status=404) - with pytest.raises(tc.project.NotFound): tc.project.from_resource_id(s, instance, "1") From 32adf0638be10deb8dba71ec083aca20f37a3a75 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 30 Jul 2020 16:12:26 -0400 Subject: [PATCH 496/632] Transfer mocks into fakes utility --- tests/tamr_client/attribute/test_attribute.py | 34 ++++++++--------- tests/tamr_client/dataset/test_dataframe.py | 22 +++++------ tests/tamr_client/dataset/test_dataset.py | 10 ++--- tests/tamr_client/dataset/test_record.py | 34 ++++++++--------- tests/tamr_client/dataset/test_unified.py | 16 ++++---- tests/tamr_client/fake.py | 35 +++++++++++++++++ tests/tamr_client/test_operation.py | 18 ++++----- tests/tamr_client/test_project.py | 10 ++--- tests/tamr_client/test_response.py | 4 +- tests/tamr_client/test_transformations.py | 14 +++---- tests/tamr_client/utils.py | 38 ------------------- 11 files changed, 116 insertions(+), 119 deletions(-) diff --git a/tests/tamr_client/attribute/test_attribute.py b/tests/tamr_client/attribute/test_attribute.py index 3fffb0ea..d34738d1 100644 --- a/tests/tamr_client/attribute/test_attribute.py +++ b/tests/tamr_client/attribute/test_attribute.py @@ -4,7 +4,7 @@ import responses import tamr_client as tc -from tests.tamr_client import utils +from tests.tamr_client import fake, utils def test_from_json(): @@ -32,8 +32,8 @@ def test_json(): @responses.activate def test_create(): - s = utils.session() - dataset = utils.dataset() + s = fake.session() + dataset = fake.dataset() attrs = tuple( [ @@ -63,7 +63,7 @@ def test_create(): @responses.activate def test_update(): - s = utils.session() + s = fake.session() url = tc.URL(path="datasets/1/attributes/RowNum") attr_json = utils.load_json("attributes.json")[0] @@ -80,7 +80,7 @@ def test_update(): @responses.activate def test_delete(): - s = utils.session() + s = fake.session() url = tc.URL(path="datasets/1/attributes/RowNum") attr_json = utils.load_json("attributes.json")[0] @@ -92,8 +92,8 @@ def test_delete(): @responses.activate def test_from_resource_id(): - s = utils.session() - dataset = utils.dataset() + s = fake.session() + dataset = fake.dataset() url = tc.URL(path=dataset.url.path + "/attributes/attr") attr_json = utils.load_json("attribute.json") @@ -105,8 +105,8 @@ def test_from_resource_id(): @responses.activate def test_from_resource_id_attribute_not_found(): - s = utils.session() - dataset = utils.dataset() + s = fake.session() + dataset = fake.dataset() url = replace(dataset.url, path=dataset.url.path + "/attributes/attr") @@ -116,8 +116,8 @@ def test_from_resource_id_attribute_not_found(): def test_create_reserved_attribute_name(): - s = utils.session() - dataset = utils.dataset() + s = fake.session() + dataset = fake.dataset() with pytest.raises(tc.attribute.ReservedName): tc.attribute.create(s, dataset, name="clusterId", is_nullable=False) @@ -125,8 +125,8 @@ def test_create_reserved_attribute_name(): @responses.activate def test_from_dataset_all(): - s = utils.session() - dataset = utils.dataset() + s = fake.session() + dataset = fake.dataset() attrs_url = replace(dataset.url, path=dataset.url.path + "/attributes") attrs_json = utils.load_json("attributes.json") @@ -145,8 +145,8 @@ def test_from_dataset_all(): @responses.activate def test_create_attribute_exists(): - s = utils.session() - dataset = utils.dataset() + s = fake.session() + dataset = fake.dataset() url = replace(dataset.url, path=dataset.url.path + "/attributes") responses.add(responses.POST, str(url), status=409) @@ -156,7 +156,7 @@ def test_create_attribute_exists(): @responses.activate def test_update_attribute_not_found(): - s = utils.session() + s = fake.session() url = tc.URL(path="datasets/1/attributes/RowNum") attr_json = utils.load_json("attributes.json")[0] @@ -169,7 +169,7 @@ def test_update_attribute_not_found(): @responses.activate def test_delete_attribute_not_found(): - s = utils.session() + s = fake.session() url = tc.URL(path="datasets/1/attributes/RowNum") attr_json = utils.load_json("attributes.json")[0] diff --git a/tests/tamr_client/dataset/test_dataframe.py b/tests/tamr_client/dataset/test_dataframe.py index 0b77cc67..65b97107 100644 --- a/tests/tamr_client/dataset/test_dataframe.py +++ b/tests/tamr_client/dataset/test_dataframe.py @@ -6,13 +6,13 @@ import responses import tamr_client as tc -from tests.tamr_client import utils +from tests.tamr_client import fake, utils @responses.activate def test_upsert(): - s = utils.session() - dataset = utils.dataset() + s = fake.session() + dataset = fake.dataset() url = tc.URL(path="datasets/1:updateRecords") updates = [ @@ -37,8 +37,8 @@ def test_upsert(): @responses.activate def test_upsert_primary_key_not_found(): - s = utils.session() - dataset = utils.dataset() + s = fake.session() + dataset = fake.dataset() df = pd.DataFrame(_records_json) @@ -48,8 +48,8 @@ def test_upsert_primary_key_not_found(): @responses.activate def test_upsert_infer_primary_key(): - s = utils.session() - dataset = utils.dataset() + s = fake.session() + dataset = fake.dataset() url = tc.URL(path="datasets/1:updateRecords") updates = [ @@ -74,8 +74,8 @@ def test_upsert_infer_primary_key(): @responses.activate def test_upsert_index_as_primary_key(): - s = utils.session() - dataset = utils.dataset() + s = fake.session() + dataset = fake.dataset() url = tc.URL(path="datasets/1:updateRecords") updates = [ @@ -104,8 +104,8 @@ def test_upsert_index_as_primary_key(): @responses.activate def test_upsert_index_column_name_collision(): - s = utils.session() - dataset = utils.dataset() + s = fake.session() + dataset = fake.dataset() df = pd.DataFrame(_records_json_2) df.index.name = "primary_key" diff --git a/tests/tamr_client/dataset/test_dataset.py b/tests/tamr_client/dataset/test_dataset.py index 7a494876..256b750f 100644 --- a/tests/tamr_client/dataset/test_dataset.py +++ b/tests/tamr_client/dataset/test_dataset.py @@ -2,13 +2,13 @@ import responses import tamr_client as tc -from tests.tamr_client import utils +from tests.tamr_client import fake, utils @responses.activate def test_from_resource_id(): - s = utils.session() - instance = utils.instance() + s = fake.session() + instance = fake.instance() dataset_json = utils.load_json("dataset.json") url = tc.URL(path="datasets/1") @@ -22,8 +22,8 @@ def test_from_resource_id(): @responses.activate def test_from_resource_id_dataset_not_found(): - s = utils.session() - instance = utils.instance() + s = fake.session() + instance = fake.instance() url = tc.URL(path="datasets/1") responses.add(responses.GET, str(url), status=404) diff --git a/tests/tamr_client/dataset/test_record.py b/tests/tamr_client/dataset/test_record.py index 3043ecb6..00315380 100644 --- a/tests/tamr_client/dataset/test_record.py +++ b/tests/tamr_client/dataset/test_record.py @@ -6,13 +6,13 @@ import responses import tamr_client as tc -from tests.tamr_client import utils +from tests.tamr_client import fake, utils @responses.activate def test_update(): - s = utils.session() - dataset = utils.dataset() + s = fake.session() + dataset = fake.dataset() url = tc.URL(path="datasets/1:updateRecords") updates = [ @@ -35,8 +35,8 @@ def test_update(): @responses.activate def test_upsert(): - s = utils.session() - dataset = utils.dataset() + s = fake.session() + dataset = fake.dataset() url = tc.URL(path="datasets/1:updateRecords") updates = [ @@ -61,8 +61,8 @@ def test_upsert(): @responses.activate def test_upsert_primary_key_not_found(): - s = utils.session() - dataset = utils.dataset() + s = fake.session() + dataset = fake.dataset() with pytest.raises(tc.primary_key.NotFound): tc.record.upsert( @@ -72,8 +72,8 @@ def test_upsert_primary_key_not_found(): @responses.activate def test_upsert_infer_primary_key(): - s = utils.session() - dataset = utils.dataset() + s = fake.session() + dataset = fake.dataset() url = tc.URL(path="datasets/1:updateRecords") updates = [ @@ -96,8 +96,8 @@ def test_upsert_infer_primary_key(): @responses.activate def test_delete(): - s = utils.session() - dataset = utils.dataset() + s = fake.session() + dataset = fake.dataset() url = tc.URL(path="datasets/1:updateRecords") deletes = [ @@ -122,8 +122,8 @@ def test_delete(): @responses.activate def test_delete_primary_key_not_found(): - s = utils.session() - dataset = utils.dataset() + s = fake.session() + dataset = fake.dataset() with pytest.raises(tc.primary_key.NotFound): tc.record.delete( @@ -133,8 +133,8 @@ def test_delete_primary_key_not_found(): @responses.activate def test_delete_infer_primary_key(): - s = utils.session() - dataset = utils.dataset() + s = fake.session() + dataset = fake.dataset() url = tc.URL(path="datasets/1:updateRecords") deletes = [ @@ -157,8 +157,8 @@ def test_delete_infer_primary_key(): @responses.activate def test_stream(): - s = utils.session() - dataset = utils.dataset() + s = fake.session() + dataset = fake.dataset() url = tc.URL(path="datasets/1/records") responses.add( diff --git a/tests/tamr_client/dataset/test_unified.py b/tests/tamr_client/dataset/test_unified.py index 38089aaa..7aafd0ed 100644 --- a/tests/tamr_client/dataset/test_unified.py +++ b/tests/tamr_client/dataset/test_unified.py @@ -2,14 +2,14 @@ import responses import tamr_client as tc -from tests.tamr_client import utils +from tests.tamr_client import fake, utils @responses.activate def test_from_project(): - s = utils.session() - instance = utils.instance() - project = utils.mastering_project() + s = fake.session() + instance = fake.instance() + project = fake.mastering_project() dataset_json = utils.load_json("dataset.json") url = tc.URL(path="projects/1/unifiedDataset") @@ -23,9 +23,9 @@ def test_from_project(): @responses.activate def test_from_project_dataset_not_found(): - s = utils.session() - instance = utils.instance() - project = utils.mastering_project() + s = fake.session() + instance = fake.instance() + project = fake.mastering_project() url = tc.URL(path="projects/1/unifiedDataset") responses.add(responses.GET, str(url), status=404) @@ -36,7 +36,7 @@ def test_from_project_dataset_not_found(): @responses.activate def test_apply_changes(): - s = utils.session() + s = fake.session() dataset_json = utils.load_json("dataset.json") dataset_url = tc.URL(path="projects/1/unifiedDataset") unified_dataset = tc.dataset.unified._from_json(dataset_url, dataset_json) diff --git a/tests/tamr_client/fake.py b/tests/tamr_client/fake.py index 777b99af..8d875d24 100644 --- a/tests/tamr_client/fake.py +++ b/tests/tamr_client/fake.py @@ -5,6 +5,9 @@ import responses +import tamr_client as tc + + tests_tc_dir = (Path(__file__) / "..").resolve() fake_json_dir = tests_tc_dir / "fake_json" @@ -31,3 +34,35 @@ def wrapper(*args, **kwargs): test_fn(*args, **kwargs) return wrapper + + +def session(): + auth = tc.UsernamePasswordAuth("username", "password") + s = tc.session.from_auth(auth) + return s + + +def instance(): + return tc.Instance() + + +def dataset(): + url = tc.URL(path="datasets/1") + dataset = tc.Dataset(url, name="dataset.csv", key_attribute_names=("primary_key",)) + return dataset + + +def unified_dataset(): + url = tc.URL(path="projects/1/unifiedDataset") + unified_dataset = tc.dataset.unified.UnifiedDataset( + url, name="dataset.csv", key_attribute_names=("primary_key",) + ) + return unified_dataset + + +def mastering_project(): + url = tc.URL(path="projects/1") + mastering_project = tc.MasteringProject( + url, name="Project 1", description="A Mastering Project" + ) + return mastering_project diff --git a/tests/tamr_client/test_operation.py b/tests/tamr_client/test_operation.py index c7786760..1b08b09a 100644 --- a/tests/tamr_client/test_operation.py +++ b/tests/tamr_client/test_operation.py @@ -2,7 +2,7 @@ import responses import tamr_client as tc -from tests.tamr_client import utils +from tests.tamr_client import fake, utils def test_operation_from_json(): @@ -18,7 +18,7 @@ def test_operation_from_json(): @responses.activate def test_operation_from_url(): - s = utils.session() + s = fake.session() url = tc.URL(path="operations/1") operation_json = utils.load_json("operation_succeeded.json") @@ -34,8 +34,8 @@ def test_operation_from_url(): @responses.activate def test_operation_from_response(): - s = utils.session() - instance = utils.instance() + s = fake.session() + instance = fake.instance() url = tc.URL(path="operations/1") operation_json = utils.load_json("operation_succeeded.json") @@ -52,8 +52,8 @@ def test_operation_from_response(): @responses.activate def test_operation_from_response_noop(): - s = utils.session() - instance = utils.instance() + s = fake.session() + instance = fake.instance() url = tc.URL(path="operations/2") responses.add(responses.GET, str(url), status=204) @@ -79,8 +79,8 @@ def test_operation_from_response_noop(): @responses.activate def test_from_resource_id(): - s = utils.session() - instance = utils.instance() + s = fake.session() + instance = fake.instance() url = tc.URL(path="operations/1") operation_json = utils.load_json("operation_succeeded.json") @@ -97,7 +97,7 @@ def test_from_resource_id(): @responses.activate def test_operation_poll(): - s = utils.session() + s = fake.session() url = tc.URL(path="operations/1") pending_operation_json = utils.load_json("operation_pending.json") diff --git a/tests/tamr_client/test_project.py b/tests/tamr_client/test_project.py index 3200cb98..3cfb6a4c 100644 --- a/tests/tamr_client/test_project.py +++ b/tests/tamr_client/test_project.py @@ -1,13 +1,13 @@ import pytest import tamr_client as tc -from tests.tamr_client import fake, utils +from tests.tamr_client import fake @fake.json def test_from_resource_id_mastering(): - s = utils.session() - instance = utils.instance() + s = fake.session() + instance = fake.instance() project = tc.project.from_resource_id(s, instance, "1") assert isinstance(project, tc.MasteringProject) @@ -17,8 +17,8 @@ def test_from_resource_id_mastering(): @fake.json def test_from_resource_id_not_found(): - s = utils.session() - instance = utils.instance() + s = fake.session() + instance = fake.instance() with pytest.raises(tc.project.NotFound): tc.project.from_resource_id(s, instance, "1") diff --git a/tests/tamr_client/test_response.py b/tests/tamr_client/test_response.py index 84d9b8d8..e9d9d6bd 100644 --- a/tests/tamr_client/test_response.py +++ b/tests/tamr_client/test_response.py @@ -3,12 +3,12 @@ import responses import tamr_client as tc -from tests.tamr_client import utils +from tests.tamr_client import fake @responses.activate def test_ndjson(): - s = utils.session() + s = fake.session() records = [{"a": 1}, {"b": 2}, {"c": 3}] url = tc.URL(path="datasets/1/records") diff --git a/tests/tamr_client/test_transformations.py b/tests/tamr_client/test_transformations.py index 85f8ec47..579d17b4 100644 --- a/tests/tamr_client/test_transformations.py +++ b/tests/tamr_client/test_transformations.py @@ -3,7 +3,7 @@ import responses import tamr_client as tc -from tests.tamr_client import utils +from tests.tamr_client import fake, utils @responses.activate @@ -22,8 +22,8 @@ def test_get_all(): responses.add(responses.GET, str(dataset_url), json=dataset_json) # test - s = utils.session() - instance = utils.instance() + s = fake.session() + instance = fake.instance() project = tc.project.from_resource_id(s, instance, "1") transforms = tc.transformations.get_all(s, project) @@ -56,8 +56,8 @@ def test_replace_all(): responses.add(responses.GET, str(dataset_url), json=dataset_json) # test - s = utils.session() - instance = utils.instance() + s = fake.session() + instance = fake.instance() project = tc.project.from_resource_id(s, instance, "1") transforms = tc.transformations._from_json(s, instance, tx_json) @@ -98,8 +98,8 @@ def test_replace_all_errors(): responses.add(responses.GET, str(dataset_url), json=dataset_json) # test - s = utils.session() - instance = utils.instance() + s = fake.session() + instance = fake.instance() project = tc.project.from_resource_id(s, instance, "1") transforms = tc.transformations._from_json(s, instance, tx_json) diff --git a/tests/tamr_client/utils.py b/tests/tamr_client/utils.py index fdfc8805..97fad809 100644 --- a/tests/tamr_client/utils.py +++ b/tests/tamr_client/utils.py @@ -2,7 +2,6 @@ from pathlib import Path from typing import Union -import tamr_client as tc data_dir = Path(__file__).parent / "data" @@ -12,43 +11,6 @@ def load_json(path: Union[str, Path]): return json.load(f) -def session(): - # TODO move to fake.py - auth = tc.UsernamePasswordAuth("username", "password") - s = tc.session.from_auth(auth) - return s - - -def instance(): - # TODO move to fake.py - return tc.Instance() - - -def dataset(): - # TODO move to fake.py - url = tc.URL(path="datasets/1") - dataset = tc.Dataset(url, name="dataset.csv", key_attribute_names=("primary_key",)) - return dataset - - -def unified_dataset(): - # TODO move to fake.py - url = tc.URL(path="projects/1/unifiedDataset") - unified_dataset = tc.dataset.unified.UnifiedDataset( - url, name="dataset.csv", key_attribute_names=("primary_key",) - ) - return unified_dataset - - -def mastering_project(): - # TODO move to fake.py - url = tc.URL(path="projects/1") - mastering_project = tc.MasteringProject( - url, name="Project 1", description="A Mastering Project" - ) - return mastering_project - - def capture_payload(request, snoop, status, response_json): """Capture request body within `snoop` so we can inspect that the request body is constructed correctly (e.g. for streaming requests). From 697278e7f86e2566051ce9d706729fa8c0eac179 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 30 Jul 2020 16:28:18 -0400 Subject: [PATCH 497/632] Include type stub for responses.RequestsMock context manager --- stubs/responses.pyi | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/stubs/responses.pyi b/stubs/responses.pyi index b4df44f3..f73d84f7 100644 --- a/stubs/responses.pyi +++ b/stubs/responses.pyi @@ -1,5 +1,5 @@ from functools import partial -from typing import Any, Dict, Optional, TypeVar +from typing import Any, ContextManager, Dict, Optional, TypeVar JsonDict = Dict[str, Any] @@ -20,3 +20,4 @@ T = TypeVar("T") def activate(T) -> T: ... def add_callback(method: Optional[str], url: Optional[str], callback: partial[Any]): ... +def RequestsMock() -> ContextManager[Any]: ... From 630302a0dfc022253cf81b5a2d22e75f6861cf7f Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 31 Jul 2020 10:38:20 -0400 Subject: [PATCH 498/632] Convert tranformation tests to use fake utility --- tests/tamr_client/data/mastering_project.json | 19 ----- tests/tamr_client/data/transformations.json | 21 ----- tests/tamr_client/fake.py | 10 +++ .../test_transformations/test_get_all.json | 62 ++++++++++++++ .../test_replace_all.json | 22 +++++ .../test_replace_all_errors.json | 11 +++ tests/tamr_client/test_transformations.py | 85 +++---------------- 7 files changed, 115 insertions(+), 115 deletions(-) delete mode 100644 tests/tamr_client/data/mastering_project.json delete mode 100644 tests/tamr_client/data/transformations.json create mode 100644 tests/tamr_client/fake_json/test_transformations/test_get_all.json create mode 100644 tests/tamr_client/fake_json/test_transformations/test_replace_all.json create mode 100644 tests/tamr_client/fake_json/test_transformations/test_replace_all_errors.json diff --git a/tests/tamr_client/data/mastering_project.json b/tests/tamr_client/data/mastering_project.json deleted file mode 100644 index 1563eec0..00000000 --- a/tests/tamr_client/data/mastering_project.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "id": "unify://unified-data/v1/projects/1", - "name": "proj", - "description": "Mastering Project", - "type": "DEDUP", - "unifiedDatasetName": "proj_unified_dataset", - "created": { - "username": "admin", - "time": "2020-04-03T14:14:18.752Z", - "version": "18" - }, - "lastModified": { - "username": "admin", - "time": "2020-04-03T14:14:20.115Z", - "version": "19" - }, - "relativeId": "projects/1", - "externalId": "58bdbe72-3c08-427d-97bd-45b16d92c79c" -} diff --git a/tests/tamr_client/data/transformations.json b/tests/tamr_client/data/transformations.json deleted file mode 100644 index ccf759a8..00000000 --- a/tests/tamr_client/data/transformations.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "parameterized": [ - { - "datasets": [], - "transformation": "SELECT *, 1 as one;" - }, - { - "datasets": [ - { - "name": "dataset 1 name", - "datasetId": "unify://unified-data/v1/datasets/1", - "relativeDatasetId": "datasets/1" - } - ], - "transformation": "SELECT *, 2 as two;" - } - ], - "unified": [ - "//Comment\nSELECT *;" - ] -} \ No newline at end of file diff --git a/tests/tamr_client/fake.py b/tests/tamr_client/fake.py index 8d875d24..d42dd45b 100644 --- a/tests/tamr_client/fake.py +++ b/tests/tamr_client/fake.py @@ -66,3 +66,13 @@ def mastering_project(): url, name="Project 1", description="A Mastering Project" ) return mastering_project + + +def transforms(): + return tc.Transformations( + input_scope=[ + tc.InputTransformation("SELECT *, 1 as one;"), + tc.InputTransformation("SELECT *, 2 as two;", datasets=[dataset()]), + ], + unified_scope=["//Comment\nSELECT *;"], + ) diff --git a/tests/tamr_client/fake_json/test_transformations/test_get_all.json b/tests/tamr_client/fake_json/test_transformations/test_get_all.json new file mode 100644 index 00000000..74c7d6c5 --- /dev/null +++ b/tests/tamr_client/fake_json/test_transformations/test_get_all.json @@ -0,0 +1,62 @@ +[ + { + "request": { + "method": "GET", + "url": "http://localhost/api/versioned/v1/projects/1/transformations" + }, + "response": { + "json": { + "parameterized": [ + { + "datasets": [], + "transformation": "SELECT *, 1 as one;" + }, + { + "datasets": [ + { + "name": "dataset 1 name", + "datasetId": "unify://unified-data/v1/datasets/1", + "relativeDatasetId": "datasets/1" + } + ], + "transformation": "SELECT *, 2 as two;" + } + ], + "unified": [ + "//Comment\nSELECT *;" + ] + } + } + }, + { + "request": { + "method": "GET", + "url": "http://localhost/api/versioned/v1/datasets/1" + }, + "response": { + "json": { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "dataset 1 name", + "description": "dataset 1 description", + "version": "dataset 1 version", + "keyAttributeNames": [ + "tamr_id" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/test_transformations/test_replace_all.json b/tests/tamr_client/fake_json/test_transformations/test_replace_all.json new file mode 100644 index 00000000..1cdad305 --- /dev/null +++ b/tests/tamr_client/fake_json/test_transformations/test_replace_all.json @@ -0,0 +1,22 @@ +[ + { + "request": { + "method": "PUT", + "url": "http://localhost/api/versioned/v1/projects/1/transformations" + }, + "response": { + "json": { + "parameterized": [ + { + "datasets": [], + "transformation": "SELECT *, 1 as one;" + } + ], + "unified": [ + "//Comment\nSELECT *;", + "//extra TX" + ] + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/test_transformations/test_replace_all_errors.json b/tests/tamr_client/fake_json/test_transformations/test_replace_all_errors.json new file mode 100644 index 00000000..85d96c9a --- /dev/null +++ b/tests/tamr_client/fake_json/test_transformations/test_replace_all_errors.json @@ -0,0 +1,11 @@ +[ + { + "request": { + "method": "PUT", + "url": "http://localhost/api/versioned/v1/projects/1/transformations" + }, + "response": { + "status": 400 + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/test_transformations.py b/tests/tamr_client/test_transformations.py index 579d17b4..3757d8c8 100644 --- a/tests/tamr_client/test_transformations.py +++ b/tests/tamr_client/test_transformations.py @@ -1,30 +1,14 @@ import pytest from requests import HTTPError -import responses import tamr_client as tc -from tests.tamr_client import fake, utils +from tests.tamr_client import fake -@responses.activate +@fake.json def test_get_all(): - # setup - project_json = utils.load_json("mastering_project.json") - project_url = tc.URL(path="projects/1") - responses.add(responses.GET, str(project_url), json=project_json) - - tx_json = utils.load_json("transformations.json") - tx_url = tc.URL(path="projects/1/transformations") - responses.add(responses.GET, str(tx_url), json=tx_json) - - dataset_json = utils.load_json("dataset.json") - dataset_url = tc.URL(path="datasets/1") - responses.add(responses.GET, str(dataset_url), json=dataset_json) - - # test s = fake.session() - instance = fake.instance() - project = tc.project.from_resource_id(s, instance, "1") + project = fake.mastering_project() transforms = tc.transformations.get_all(s, project) @@ -40,71 +24,22 @@ def test_get_all(): assert transforms.unified_scope[0] == "//Comment\nSELECT *;" -@responses.activate +@fake.json def test_replace_all(): - # setup - project_json = utils.load_json("mastering_project.json") - project_url = tc.URL(path="projects/1") - responses.add(responses.GET, str(project_url), json=project_json) - - tx_json = utils.load_json("transformations.json") - tx_url = tc.URL(path="projects/1/transformations") - responses.add(responses.GET, str(tx_url), json=tx_json) - - dataset_json = utils.load_json("dataset.json") - dataset_url = tc.URL(path="datasets/1") - responses.add(responses.GET, str(dataset_url), json=dataset_json) - - # test s = fake.session() - instance = fake.instance() - project = tc.project.from_resource_id(s, instance, "1") + project = fake.mastering_project() + transforms = fake.transforms() - transforms = tc.transformations._from_json(s, instance, tx_json) transforms.unified_scope.append("//extra TX") transforms.input_scope.pop(1) + tc.transformations.replace_all(s, project, transforms) - responses.add( - responses.PUT, str(tx_url), json=tc.transformations._to_json(transforms) - ) - - r = tc.transformations.replace_all(s, project, transforms) - - posted_tx = tc.transformations._from_json(s, project.url.instance, r.json()) - - assert len(posted_tx.input_scope) == 1 - assert len(posted_tx.unified_scope) == 2 - assert len(posted_tx.input_scope[0].datasets) == 0 - assert posted_tx.input_scope[0].transformation == "SELECT *, 1 as one;" - - assert posted_tx.unified_scope[0] == "//Comment\nSELECT *;" - assert posted_tx.unified_scope[1] == "//extra TX" - - -@responses.activate +@fake.json def test_replace_all_errors(): - # setup - project_json = utils.load_json("mastering_project.json") - project_url = tc.URL(path="projects/1") - responses.add(responses.GET, str(project_url), json=project_json) - - tx_json = utils.load_json("transformations.json") - tx_url = tc.URL(path="projects/1/transformations") - responses.add(responses.GET, str(tx_url), json=tx_json) - - dataset_json = utils.load_json("dataset.json") - dataset_url = tc.URL(path="datasets/1") - responses.add(responses.GET, str(dataset_url), json=dataset_json) - - # test s = fake.session() - instance = fake.instance() - project = tc.project.from_resource_id(s, instance, "1") - - transforms = tc.transformations._from_json(s, instance, tx_json) - - responses.add(responses.PUT, str(tx_url), status=400) + project = fake.mastering_project() + transforms = fake.transforms() with pytest.raises(HTTPError): tc.transformations.replace_all(s, project, transforms) From e6bd9c620f986f5efe91b899ee558379defe5e7b Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 31 Jul 2020 10:59:02 -0400 Subject: [PATCH 499/632] Document description and usage of fake testing utilities Also, add type annotations to fake resource providers --- tests/tamr_client/fake.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/tests/tamr_client/fake.py b/tests/tamr_client/fake.py index d42dd45b..a0f864fb 100644 --- a/tests/tamr_client/fake.py +++ b/tests/tamr_client/fake.py @@ -1,3 +1,9 @@ +""" +Utilities for faking Tamr resources and server responses for testing. + +For more, see "How to write tests" in the Contributor guide. +""" + from functools import wraps from inspect import getfile from json import load @@ -19,6 +25,11 @@ def _to_kwargs(fake): def json(test_fn): + """Intercept API requests and respond with fake JSON data. + + Will look in fake_json directory for data corresponding to the decorated test. + Data format is a JSON list of request/response pairs in order of execution. + """ test_file = Path(getfile(test_fn)) fakes_mod_path = fake_json_dir / test_file.relative_to(tests_tc_dir).with_suffix("") @@ -36,23 +47,23 @@ def wrapper(*args, **kwargs): return wrapper -def session(): +def session() -> tc.Session: auth = tc.UsernamePasswordAuth("username", "password") s = tc.session.from_auth(auth) return s -def instance(): +def instance() -> tc.Instance: return tc.Instance() -def dataset(): +def dataset() -> tc.Dataset: url = tc.URL(path="datasets/1") dataset = tc.Dataset(url, name="dataset.csv", key_attribute_names=("primary_key",)) return dataset -def unified_dataset(): +def unified_dataset() -> tc.UnifiedDataset: url = tc.URL(path="projects/1/unifiedDataset") unified_dataset = tc.dataset.unified.UnifiedDataset( url, name="dataset.csv", key_attribute_names=("primary_key",) @@ -60,7 +71,7 @@ def unified_dataset(): return unified_dataset -def mastering_project(): +def mastering_project() -> tc.MasteringProject: url = tc.URL(path="projects/1") mastering_project = tc.MasteringProject( url, name="Project 1", description="A Mastering Project" @@ -68,7 +79,7 @@ def mastering_project(): return mastering_project -def transforms(): +def transforms() -> tc.Transformations: return tc.Transformations( input_scope=[ tc.InputTransformation("SELECT *, 1 as one;"), From 42f5368c097e168ac16efd54d417b6715624bf8a Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 31 Jul 2020 11:03:00 -0400 Subject: [PATCH 500/632] Add path shortcut for fake JSON data If `url` is provided, use that. Otherwise, if `path` is provided prepend with the default instance origin and vAPI base path to get the URL. --- tests/tamr_client/fake.py | 8 +++++++- .../test_project/test_from_resource_id_mastering.json | 2 +- .../test_project/test_from_resource_id_not_found.json | 2 +- .../fake_json/test_transformations/test_get_all.json | 4 ++-- .../fake_json/test_transformations/test_replace_all.json | 2 +- .../test_transformations/test_replace_all_errors.json | 2 +- 6 files changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/tamr_client/fake.py b/tests/tamr_client/fake.py index a0f864fb..9cc0995d 100644 --- a/tests/tamr_client/fake.py +++ b/tests/tamr_client/fake.py @@ -21,7 +21,13 @@ def _to_kwargs(fake): req = fake["request"] resp = fake["response"] - return {**req, **resp} + + url = req.get("url") + if url is None: + path = req.get("path") + url = "http://localhost/api/versioned/v1/" + path + + return dict(method=req["method"], url=url, **resp) def json(test_fn): diff --git a/tests/tamr_client/fake_json/test_project/test_from_resource_id_mastering.json b/tests/tamr_client/fake_json/test_project/test_from_resource_id_mastering.json index e62dbb2e..96c78c8d 100644 --- a/tests/tamr_client/fake_json/test_project/test_from_resource_id_mastering.json +++ b/tests/tamr_client/fake_json/test_project/test_from_resource_id_mastering.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "url": "http://localhost/api/versioned/v1/projects/1" + "path": "projects/1" }, "response": { "json": { diff --git a/tests/tamr_client/fake_json/test_project/test_from_resource_id_not_found.json b/tests/tamr_client/fake_json/test_project/test_from_resource_id_not_found.json index 7a93f7ee..26ee176c 100644 --- a/tests/tamr_client/fake_json/test_project/test_from_resource_id_not_found.json +++ b/tests/tamr_client/fake_json/test_project/test_from_resource_id_not_found.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "url": "http://localhost/api/versioned/v1/projects/1" + "path": "projects/1" }, "response": { "status": 404 diff --git a/tests/tamr_client/fake_json/test_transformations/test_get_all.json b/tests/tamr_client/fake_json/test_transformations/test_get_all.json index 74c7d6c5..a108b34c 100644 --- a/tests/tamr_client/fake_json/test_transformations/test_get_all.json +++ b/tests/tamr_client/fake_json/test_transformations/test_get_all.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "url": "http://localhost/api/versioned/v1/projects/1/transformations" + "path": "projects/1/transformations" }, "response": { "json": { @@ -31,7 +31,7 @@ { "request": { "method": "GET", - "url": "http://localhost/api/versioned/v1/datasets/1" + "path": "datasets/1" }, "response": { "json": { diff --git a/tests/tamr_client/fake_json/test_transformations/test_replace_all.json b/tests/tamr_client/fake_json/test_transformations/test_replace_all.json index 1cdad305..cf69b1c5 100644 --- a/tests/tamr_client/fake_json/test_transformations/test_replace_all.json +++ b/tests/tamr_client/fake_json/test_transformations/test_replace_all.json @@ -2,7 +2,7 @@ { "request": { "method": "PUT", - "url": "http://localhost/api/versioned/v1/projects/1/transformations" + "path": "projects/1/transformations" }, "response": { "json": { diff --git a/tests/tamr_client/fake_json/test_transformations/test_replace_all_errors.json b/tests/tamr_client/fake_json/test_transformations/test_replace_all_errors.json index 85d96c9a..f692d9fd 100644 --- a/tests/tamr_client/fake_json/test_transformations/test_replace_all_errors.json +++ b/tests/tamr_client/fake_json/test_transformations/test_replace_all_errors.json @@ -2,7 +2,7 @@ { "request": { "method": "PUT", - "url": "http://localhost/api/versioned/v1/projects/1/transformations" + "path": "projects/1/transformations" }, "response": { "status": 400 From 47f08adc388e52e54ce5eec9b89af01b15e24328 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 31 Jul 2020 22:56:12 -0400 Subject: [PATCH 501/632] Add "How to write tests" in Contributor guide --- docs/contributor-guide.md | 2 +- docs/contributor-guide/how-to-write-tests.md | 78 ++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 docs/contributor-guide/how-to-write-tests.md diff --git a/docs/contributor-guide.md b/docs/contributor-guide.md index b0537e57..9b8b0c5f 100644 --- a/docs/contributor-guide.md +++ b/docs/contributor-guide.md @@ -25,9 +25,9 @@ Submit feature requests as [Github issues](https://github.com/Datatamer/tamr-cli * [Run dev tasks](contributor-guide/dev-tasks) * [Configure your text editor](contributor-guide/text-editor) * [Read the style guide](contributor-guide/style-guide) +* [How to write tests](contributor-guide/how-to-write-tests) * [Submit a pull request](contributor-guide/pull-request) - ## Maintainers Maintainer responsabilities: diff --git a/docs/contributor-guide/how-to-write-tests.md b/docs/contributor-guide/how-to-write-tests.md new file mode 100644 index 00000000..6aa543a5 --- /dev/null +++ b/docs/contributor-guide/how-to-write-tests.md @@ -0,0 +1,78 @@ +# How to write tests + +Our test suite uses `pytest`. + +See the [pytest docs](https://docs.pytest.org/en/stable/) for: +- how to run specific tests +- how to capture `print` output for debugging tests +- etc... + +Note that you will need to pass any `pytest` arguments after `--` so that `nox` passes the arguments correctly to `pytest`: + +```sh +prn -s test-3.6 -- -s tests/tamr_client/test_project.py::test_from_resource_id_mastering +``` + +## Unit tests + +Each unit test: +- must be in a Python file whose name starts with `test_` +- must be a function whose name starts with `test_` +- should test *one* specific feature. +- should use `tests.tamr_client.fake` utility to fake resources and Tamr server responses as necessary + +For example, testing a simple feature that does not require communication with a Tamr server could look like: + +```python +# test_my_feature.py +import tamr_client as tc +from tests.tamr_client import fake + +def test_my_feature_works(): + # prerequisites + p = fake.project() + d = fake.dataset() + + # test my feature + result = tc.my_feature(p, d) + assert result.is_correct() +``` + +After using the `fake` utilities to set up your prerequisites, +the rest of the test code should be as representative of real user code as possible. + +Test code that exercises the feature should not contain any test-specific logic. + +### Faking responses + +If the tested feature requires communication with a Tamr server, +you will need to fake Tamr server responses. + +In general, any feature that takes a session argument will need faked responses. + +You can fake responses via the `@fake.json` decorator: + +```python +# test_my_feature.py +import tamr_client as tc +from tests.tamr_client import fake + +@fake.json +def test_my_feature(): + # prerequisites + s = fake.session() + p = fake.project() + + # test my feature + result = tc.my_feature(s, p) + assert result.is_correct() +``` + +`@fake.json` will look for a corresponding fake JSON file within `tests/tamr_client/fake_json`, +specifically `tests/tamr_client/fake_json//`. + +In the example, that would be `tests/tamr_client/fake_json/test_my_feature/test_my_feature_works.json`. + +The fake JSON file should be formatted as a list of request/response pairs in order of execution. + +For a real examples, see existing fake JSON files within `tests/tamr_client/fake_json`. \ No newline at end of file From a7cb558cbb9f78b4429959d515b83fca4b5fa2a1 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 31 Jul 2020 22:58:21 -0400 Subject: [PATCH 502/632] Fix typo: "pre-requisites" -> "prerequisites" --- docs/contributor-guide/install.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contributor-guide/install.md b/docs/contributor-guide/install.md index 89b0d67d..a54c11bf 100644 --- a/docs/contributor-guide/install.md +++ b/docs/contributor-guide/install.md @@ -1,6 +1,6 @@ # Installation -### Pre-requisites +### Prerequisites 1. Install [build dependencies for pyenv](https://github.com/pyenv/pyenv/wiki#suggested-build-environment) 2. Install [pyenv](https://github.com/pyenv/pyenv#installation) From aeea8c7e13329b73419cd1f0e8cfdbca120658f8 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 6 Aug 2020 22:21:38 -0400 Subject: [PATCH 503/632] Quick fix to record update function and tests. --- tamr_client/dataset/record.py | 2 +- tests/tamr_client/utils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tamr_client/dataset/record.py b/tamr_client/dataset/record.py index a2a8b75c..f7a0916f 100644 --- a/tamr_client/dataset/record.py +++ b/tamr_client/dataset/record.py @@ -27,7 +27,7 @@ def _update(session: Session, dataset: Dataset, updates: Iterable[Dict]) -> Json Raises: requests.HTTPError: If an HTTP error is encountered """ - stringified_updates = (json.dumps(update) for update in updates) + stringified_updates = (json.dumps(update).encode("utf-8") for update in updates) # `requests` accepts a generator for `data` param, but stubs for `requests` in https://github.com/python/typeshed expects this to be a file-like object io_updates = cast(IO, stringified_updates) r = session.post( diff --git a/tests/tamr_client/utils.py b/tests/tamr_client/utils.py index 97fad809..47578b3e 100644 --- a/tests/tamr_client/utils.py +++ b/tests/tamr_client/utils.py @@ -16,7 +16,7 @@ def capture_payload(request, snoop, status, response_json): See https://github.com/getsentry/responses#dynamic-responses """ - snoop["payload"] = list(request.body) + snoop["payload"] = [x.decode("utf-8") for x in request.body] return status, {}, json.dumps(response_json) From 95767d277b0f608d10093a31383e112ec0da17ba Mon Sep 17 00:00:00 2001 From: Afsana Afzal Date: Fri, 7 Aug 2020 14:43:05 -0400 Subject: [PATCH 504/632] docs to integrate black with intelliJ --- docs/contributor-guide/text-editor.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/contributor-guide/text-editor.md b/docs/contributor-guide/text-editor.md index 3ad0fc1e..68c05bf5 100644 --- a/docs/contributor-guide/text-editor.md +++ b/docs/contributor-guide/text-editor.md @@ -5,4 +5,7 @@ - [linter-flake8](https://atom.io/packages/linter-flake8) ### VS Code -- [Python](https://marketplace.visualstudio.com/items?itemName=ms-python.python) \ No newline at end of file +- [Python](https://marketplace.visualstudio.com/items?itemName=ms-python.python) + +### IntelliJ +- [Black](https://black.readthedocs.io/en/stable/editor_integration.html#pycharm-intellij-idea) From 7b0d75dc8b7e4e0c3f4b08ccd974781226f6f066 Mon Sep 17 00:00:00 2001 From: Dominick Olivito Date: Fri, 7 Aug 2020 16:42:51 -0400 Subject: [PATCH 505/632] Update docs to improve null handling in dataframes --- docs/user-guide/pandas.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/user-guide/pandas.md b/docs/user-guide/pandas.md index ac513f4f..2bc1844d 100644 --- a/docs/user-guide/pandas.md +++ b/docs/user-guide/pandas.md @@ -97,13 +97,13 @@ stored in the removed attributes. ## Upload Dataframe as Dataset ### Create New Dataset -To create a new dataset and upload data, the convenience function `dataset.create_from_dataframe()` can be used. +To create a new dataset and upload data, the convenience function `datasets.create_from_dataframe()` can be used. Note that Tamr will throw an error if columns aren't generally formatted as strings. (The exception being geospatial columns. For that, see the geospatial examples.) -In order to achieve this, the following code will transform the column types to string. +To format values as strings while preserving null information, specify `dtype=object` when creating a dataframe from a csv file. ```python -df = df.astype(str) +df = pd.read_csv("my_file.csv", dtype=object) ``` Creating the dataset is as easy as calling: @@ -111,6 +111,13 @@ Creating the dataset is as easy as calling: tamr.datasets.create_from_dataframe(df, 'primaryKey', 'my_new_dataset') ``` +For an already-existing dataframe, the columns can be converted to strings using: +```python +df = df.astype(str) +``` +Note, however, that converting this way will cause any `NaN` or `None` values to become strings like `'nan'` +that will persist into the created Tamr dataset. + ### Changing Values #### Making Changes: In Memory From 19b862d071631ea8e88f636649d5f871916f2c83 Mon Sep 17 00:00:00 2001 From: Afsana Afzal Date: Tue, 11 Aug 2020 17:20:44 -0400 Subject: [PATCH 506/632] Ignore venv directory when linting --- .flake8 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.flake8 b/.flake8 index 06123d29..9e31c973 100644 --- a/.flake8 +++ b/.flake8 @@ -6,7 +6,7 @@ ignore = E203, E266, E501, W503, F403 max-line-length = 88 max-complexity = 18 select = B,C,E,F,I,W,T4,B9 -exclude = build,.venv,*.egg-info +exclude = build,venv,.venv,*.egg-info per-file-ignores = tamr_client/__init__.py:E402,F401,I100,I202 tamr_client/*/__init__.py:F401 From a8148f22daa38ffadb9864b2391c68d82010b571 Mon Sep 17 00:00:00 2001 From: Afsana Afzal Date: Tue, 11 Aug 2020 17:23:24 -0400 Subject: [PATCH 507/632] Add Ambiguous exception for datasets --- tamr_client/dataset/_dataset.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tamr_client/dataset/_dataset.py b/tamr_client/dataset/_dataset.py index 390d7f0d..1430f17a 100644 --- a/tamr_client/dataset/_dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -16,6 +16,12 @@ class NotFound(TamrClientException): pass +class Ambiguous(TamrClientException): + """Raised when referencing a dataset by name that matches multiple possible targets.""" + + pass + + def from_resource_id(session: Session, instance: Instance, id: str) -> Dataset: """Get dataset by resource ID From 788f9d0dd4081f283c23a7a69ddf70f26cd81de2 Mon Sep 17 00:00:00 2001 From: Afsana Afzal Date: Wed, 12 Aug 2020 12:28:01 -0400 Subject: [PATCH 508/632] Document how to run tests without prn alias --- docs/contributor-guide/dev-tasks.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/contributor-guide/dev-tasks.md b/docs/contributor-guide/dev-tasks.md index f422b831..6fb2c492 100644 --- a/docs/contributor-guide/dev-tasks.md +++ b/docs/contributor-guide/dev-tasks.md @@ -74,7 +74,8 @@ See [`nox --list`](https://nox.thea.codes/en/stable/tutorial.html#selecting-whic To run specific tests, see [these pytest docs](https://docs.pytest.org/en/latest/usage.html#specifying-tests-selecting-tests) and pass `pytest` args after `--` e.g.: ```sh -prn -s test -- tests/unit/test_attribute.py +prn -s test -- tests/unit/test_attribute.py # with alias +poetry run nox -s test -- tests/unit/test_attribute.py # without alias ``` From c30c6f58addae8080362c212972da7f3fc4be6c9 Mon Sep 17 00:00:00 2001 From: Afsana Afzal Date: Wed, 12 Aug 2020 12:29:57 -0400 Subject: [PATCH 509/632] Support categorization projects and labels --- CHANGELOG.md | 3 +- docs/beta.md | 1 + docs/beta/categorization.md | 3 + docs/beta/categorization/project.rst | 6 ++ tamr_client/__init__.py | 2 + tamr_client/_types/__init__.py | 2 +- tamr_client/_types/project.py | 19 +++- tamr_client/categorization/__init__.py | 5 + tamr_client/categorization/project.py | 55 +++++++++++ tamr_client/project.py | 10 +- tests/tamr_client/categorization/__init__.py | 0 .../categorization/test_project.py | 13 +++ tests/tamr_client/fake.py | 8 ++ .../test_project/test_manual_labels.json | 99 +++++++++++++++++++ .../test_from_resource_id_categorization.json | 29 ++++++ tests/tamr_client/test_project.py | 11 +++ 16 files changed, 256 insertions(+), 10 deletions(-) create mode 100644 docs/beta/categorization.md create mode 100644 docs/beta/categorization/project.rst create mode 100644 tamr_client/categorization/__init__.py create mode 100644 tamr_client/categorization/project.py create mode 100644 tests/tamr_client/categorization/__init__.py create mode 100644 tests/tamr_client/categorization/test_project.py create mode 100644 tests/tamr_client/fake_json/categorization/test_project/test_manual_labels.json create mode 100644 tests/tamr_client/fake_json/test_project/test_from_resource_id_categorization.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 983c6a40..f8b5b45e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ **NEW FEATURES** - [#383](https://github.com/Datatamer/tamr-client/issues/383) Now able to create an Operation from Job resource id - + - [#425](https://github.com/Datatamer/tamr-client/pull/425) Now able to get, update and delete manual labels for Categorization projects + ## 0.12.0 **BETA** Important: Do not use BETA features for production workflows. diff --git a/docs/beta.md b/docs/beta.md index 42202be7..28560c30 100644 --- a/docs/beta.md +++ b/docs/beta.md @@ -7,6 +7,7 @@ * [Attribute](beta/attribute) * [Auth](beta/auth) + * [Categorization](beta/categorization) * [Dataset](beta/dataset) * [Instance](beta/instance) * [Mastering](beta/mastering) diff --git a/docs/beta/categorization.md b/docs/beta/categorization.md new file mode 100644 index 00000000..c9347aa6 --- /dev/null +++ b/docs/beta/categorization.md @@ -0,0 +1,3 @@ +# Categoriation + + * [Project](/beta/categorization/project) diff --git a/docs/beta/categorization/project.rst b/docs/beta/categorization/project.rst new file mode 100644 index 00000000..dc574180 --- /dev/null +++ b/docs/beta/categorization/project.rst @@ -0,0 +1,6 @@ +Categorization Project +====================== + +.. autoclass:: tamr_client.CategorizationProject + +.. autofunction:: tamr_client.categorization.project.manual_labels \ No newline at end of file diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 281123d1..f6e6f5cb 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -20,6 +20,7 @@ AnyDataset, Attribute, AttributeType, + CategorizationProject, Dataset, InputTransformation, Instance, @@ -38,6 +39,7 @@ ############### from tamr_client import attribute +from tamr_client import categorization from tamr_client import dataset from tamr_client import instance from tamr_client import mastering diff --git a/tamr_client/_types/__init__.py b/tamr_client/_types/__init__.py index 66e1b909..203daa69 100644 --- a/tamr_client/_types/__init__.py +++ b/tamr_client/_types/__init__.py @@ -20,7 +20,7 @@ from tamr_client._types.instance import Instance from tamr_client._types.json import JsonDict from tamr_client._types.operation import Operation -from tamr_client._types.project import MasteringProject, Project +from tamr_client._types.project import CategorizationProject, MasteringProject, Project from tamr_client._types.session import Session from tamr_client._types.transformations import InputTransformation, Transformations from tamr_client._types.url import URL diff --git a/tamr_client/_types/project.py b/tamr_client/_types/project.py index 6df4fbc3..d382684f 100644 --- a/tamr_client/_types/project.py +++ b/tamr_client/_types/project.py @@ -21,4 +21,21 @@ class MasteringProject: description: Optional[str] = None -Project = Union[MasteringProject] +@dataclass(frozen=True) +class CategorizationProject: + """A Tamr Categorization project + + See https://docs.tamr.com/reference/the-project-object + + Args: + url + name + description + """ + + url: URL + name: str + description: Optional[str] = None + + +Project = Union[MasteringProject, CategorizationProject] diff --git a/tamr_client/categorization/__init__.py b/tamr_client/categorization/__init__.py new file mode 100644 index 00000000..61f375da --- /dev/null +++ b/tamr_client/categorization/__init__.py @@ -0,0 +1,5 @@ +""" +Tamr - Categorization +See https://docs.tamr.com/docs/overall-workflow-classification +""" +from tamr_client.categorization import project diff --git a/tamr_client/categorization/project.py b/tamr_client/categorization/project.py new file mode 100644 index 00000000..46c8e5c4 --- /dev/null +++ b/tamr_client/categorization/project.py @@ -0,0 +1,55 @@ +from tamr_client._types import ( + CategorizationProject, + Dataset, + Instance, + JsonDict, + Session, + URL, +) +from tamr_client.dataset import _dataset, unified + + +def _from_json(url: URL, data: JsonDict) -> CategorizationProject: + """Make Categorization project from JSON data (deserialize) + + Args: + url: Project URL + data: Project JSON data from Tamr server + """ + return CategorizationProject( + url, name=data["name"], description=data.get("description") + ) + + +def manual_labels( + session: Session, instance: Instance, project: CategorizationProject +) -> Dataset: + """Get manual labels from a Categorization project + Args: + instance: Tamr instance containing project + project: Tamr project containing labels + + Returns: + Dataset containing manual labels + + Raises: + _dataset.NotFound: If no dataset could be found at the specified URL + Ambiguous: If multiple targets match dataset name + """ + unified_dataset = unified.from_project( + session=session, instance=instance, project=project + ) + labels_dataset_name = unified_dataset.name + "_manual_categorizations" + datasets_url = URL(instance=instance, path="datasets") + r = session.get( + url=str(datasets_url), params={"filter": f"name=={labels_dataset_name}"} + ) + matches = r.json() + if len(matches) == 0: + raise _dataset.NotFound(str(r.url)) + if len(matches) > 1: + raise _dataset.Ambiguous(str(r.url)) + + dataset_path = matches[0]["relativeId"] + dataset_url = URL(instance=instance, path=dataset_path) + return _dataset._from_url(session=session, url=dataset_url) diff --git a/tamr_client/project.py b/tamr_client/project.py index 66549680..8d5ae307 100644 --- a/tamr_client/project.py +++ b/tamr_client/project.py @@ -1,5 +1,6 @@ from tamr_client import response from tamr_client._types import Instance, JsonDict, Project, Session, URL +from tamr_client.categorization import project as categorization_project from tamr_client.exception import TamrClientException from tamr_client.mastering import project as mastering_project @@ -13,13 +14,10 @@ class NotFound(TamrClientException): def from_resource_id(session: Session, instance: Instance, id: str) -> Project: """Get project by resource ID - Fetches project from Tamr server - Args: instance: Tamr instance containing this dataset id: Project ID - Raises: project.NotFound: If no project could be found at the specified URL. Corresponds to a 404 HTTP error. @@ -31,12 +29,9 @@ def from_resource_id(session: Session, instance: Instance, id: str) -> Project: def _from_url(session: Session, url: URL) -> Project: """Get project by URL - Fetches project from Tamr server - Args: url: Project URL - Raises: NotFound: If no project could be found at the specified URL. Corresponds to a 404 HTTP error. @@ -51,7 +46,6 @@ def _from_url(session: Session, url: URL) -> Project: def _from_json(url: URL, data: JsonDict) -> Project: """Make project from JSON data (deserialize) - Args: url: Project URL data: Project JSON data from Tamr server @@ -59,5 +53,7 @@ def _from_json(url: URL, data: JsonDict) -> Project: proj_type = data["type"] if proj_type == "DEDUP": return mastering_project._from_json(url, data) + elif proj_type == "CATEGORIZATION": + return categorization_project._from_json(url, data) else: raise ValueError(f"Unrecognized project type '{proj_type}' in {repr(data)}") diff --git a/tests/tamr_client/categorization/__init__.py b/tests/tamr_client/categorization/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/tamr_client/categorization/test_project.py b/tests/tamr_client/categorization/test_project.py new file mode 100644 index 00000000..b4a8e020 --- /dev/null +++ b/tests/tamr_client/categorization/test_project.py @@ -0,0 +1,13 @@ +import tamr_client as tc +from tests.tamr_client import fake + + +@fake.json +def test_manual_labels(): + s = fake.session() + instance = fake.instance() + project = fake.categorization_project() + + tc.categorization.project.manual_labels( + session=s, instance=instance, project=project + ) diff --git a/tests/tamr_client/fake.py b/tests/tamr_client/fake.py index 9cc0995d..9efa45a9 100644 --- a/tests/tamr_client/fake.py +++ b/tests/tamr_client/fake.py @@ -85,6 +85,14 @@ def mastering_project() -> tc.MasteringProject: return mastering_project +def categorization_project() -> tc.CategorizationProject: + url = tc.URL(path="projects/2") + categorization_project = tc.CategorizationProject( + url, name="Project 2", description="A Categorization Project" + ) + return categorization_project + + def transforms() -> tc.Transformations: return tc.Transformations( input_scope=[ diff --git a/tests/tamr_client/fake_json/categorization/test_project/test_manual_labels.json b/tests/tamr_client/fake_json/categorization/test_project/test_manual_labels.json new file mode 100644 index 00000000..0101724e --- /dev/null +++ b/tests/tamr_client/fake_json/categorization/test_project/test_manual_labels.json @@ -0,0 +1,99 @@ +[ + { + "request": { + "method": "GET", + "path": "projects/2/unifiedDataset" + }, + "response": { + "json": { + "id": "unify://unified-data/v1/datasets/161", + "name": "Party_Categorization_Unified_Dataset", + "description": "", + "version": "3607", + "keyAttributeNames": [ + "tamr_id" + ], + "tags": [], + "created": { + "username": "afsana.afzal", + "time": "2020-05-21T15:18:38.575Z", + "version": "18336" + }, + "lastModified": { + "username": "workflow.bot", + "time": "2020-06-18T15:18:30.833Z", + "version": "149940" + }, + "relativeId": "datasets/161", + "upstreamDatasetIds": [ + "unify://unified-data/v1/datasets/106" + ], + "externalId": "Party_Categorization_Unified_Dataset" + } + } + }, + { + "request": { + "method": "GET", + "path": "datasets?filter=name==Party_Categorization_Unified_Dataset_manual_categorizations" + }, + "response": { + "json": [ + { + "id": "unify://unified-data/v1/datasets/167", + "name": "Party_Categorization_Unified_Dataset_manual_categorizations", + "description": "Manual categorizations", + "version": "2992", + "keyAttributeNames": [ + "recordId" + ], + "tags": [], + "created": { + "username": "afsana.afzal", + "time": "2020-06-01T20:49:46.549Z", + "version": "57920" + }, + "lastModified": { + "username": "workflow.bot", + "time": "2020-06-18T15:32:44.631Z", + "version": "150069" + }, + "relativeId": "datasets/167", + "upstreamDatasetIds": [], + "externalId": "Party_Categorization_Unified_Dataset_manual_categorizations" + } + ] + } + }, + { + "request": { + "method": "GET", + "path": "datasets/167" + }, + "response": { + "json": { + "id": "unify://unified-data/v1/datasets/167", + "name": "Party_Categorization_Unified_Dataset_manual_categorizations", + "description": "Manual categorizations", + "version": "2992", + "keyAttributeNames": [ + "recordId" + ], + "tags": [], + "created": { + "username": "afsana.afzal", + "time": "2020-06-01T20:49:46.549Z", + "version": "57920" + }, + "lastModified": { + "username": "workflow.bot", + "time": "2020-06-18T15:32:44.631Z", + "version": "150069" + }, + "relativeId": "datasets/167", + "upstreamDatasetIds": [], + "externalId": "Party_Categorization_Unified_Dataset_manual_categorizations" + } + } + } +] diff --git a/tests/tamr_client/fake_json/test_project/test_from_resource_id_categorization.json b/tests/tamr_client/fake_json/test_project/test_from_resource_id_categorization.json new file mode 100644 index 00000000..1c87b8f3 --- /dev/null +++ b/tests/tamr_client/fake_json/test_project/test_from_resource_id_categorization.json @@ -0,0 +1,29 @@ +[ + { + "request": { + "method": "GET", + "path": "projects/2" + }, + "response": { + "json": { + "id": "unify://unified-data/v1/projects/2", + "name": "Party Categorization", + "description": "Categorizes organization at the Party/Domestic level", + "type": "CATEGORIZATION", + "unifiedDatasetName": "party_categorization_unified_dataset", + "created": { + "username": "admin", + "time": "2020-08-04T14:54:11.767Z", + "version": "20" + }, + "lastModified": { + "username": "admin", + "time": "2020-08-04T14:54:11.767Z", + "version": "21" + }, + "relativeId": "projects/2", + "externalId": "98f9e4ee-1a35-4242-917d-1163363d5411" + } + } + } +] diff --git a/tests/tamr_client/test_project.py b/tests/tamr_client/test_project.py index 3cfb6a4c..eb209228 100644 --- a/tests/tamr_client/test_project.py +++ b/tests/tamr_client/test_project.py @@ -15,6 +15,17 @@ def test_from_resource_id_mastering(): assert project.description == "Mastering Project" +@fake.json +def test_from_resource_id_categorization(): + s = fake.session() + instance = fake.instance() + + project = tc.project.from_resource_id(s, instance, "2") + assert isinstance(project, tc.CategorizationProject) + assert project.name == "Party Categorization" + assert project.description == "Categorizes organization at the Party/Domestic level" + + @fake.json def test_from_resource_id_not_found(): s = fake.session() From 1bad7d459401feb86b0966fdc3b15febb06ddf82 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 6 Aug 2020 19:20:17 -0400 Subject: [PATCH 510/632] Move tc.attribute.from_dataset_all function to tc.dataset.attributes, and related tests. --- tamr_client/attribute/__init__.py | 1 - tamr_client/attribute/_attribute.py | 27 +---------------- tamr_client/dataset/__init__.py | 2 +- tamr_client/dataset/_dataset.py | 30 ++++++++++++++++++- tests/tamr_client/attribute/test_attribute.py | 20 ------------- tests/tamr_client/dataset/test_dataset.py | 22 ++++++++++++++ 6 files changed, 53 insertions(+), 49 deletions(-) diff --git a/tamr_client/attribute/__init__.py b/tamr_client/attribute/__init__.py index 847b6fbb..55d4b33d 100644 --- a/tamr_client/attribute/__init__.py +++ b/tamr_client/attribute/__init__.py @@ -5,7 +5,6 @@ AlreadyExists, create, delete, - from_dataset_all, from_resource_id, NotFound, ReservedName, diff --git a/tamr_client/attribute/_attribute.py b/tamr_client/attribute/_attribute.py index 95538395..ccc422e3 100644 --- a/tamr_client/attribute/_attribute.py +++ b/tamr_client/attribute/_attribute.py @@ -3,7 +3,7 @@ """ from copy import deepcopy from dataclasses import replace -from typing import Optional, Tuple +from typing import Optional from tamr_client import response from tamr_client._types import Attribute, AttributeType, Dataset, JsonDict, Session, URL @@ -105,31 +105,6 @@ def _from_json(url: URL, data: JsonDict) -> Attribute: ) -def from_dataset_all(session: Session, dataset: Dataset) -> Tuple[Attribute, ...]: - """Get all attributes from a dataset - - Args: - dataset: Dataset containing the desired attributes - - Returns: - The attributes for the specified dataset - - Raises: - requests.HTTPError: If an HTTP error is encountered. - """ - attrs_url = replace(dataset.url, path=dataset.url.path + "/attributes") - r = session.get(str(attrs_url)) - attrs_json = response.successful(r).json() - - attrs = [] - for attr_json in attrs_json: - id = attr_json["name"] - attr_url = replace(attrs_url, path=attrs_url.path + f"/{id}") - attr = _from_json(attr_url, attr_json) - attrs.append(attr) - return tuple(attrs) - - def to_json(attr: Attribute) -> JsonDict: """Serialize attribute into JSON diff --git a/tamr_client/dataset/__init__.py b/tamr_client/dataset/__init__.py index a78952af..e0de52d6 100644 --- a/tamr_client/dataset/__init__.py +++ b/tamr_client/dataset/__init__.py @@ -1,2 +1,2 @@ from tamr_client.dataset import dataframe, record, unified -from tamr_client.dataset._dataset import from_resource_id, NotFound +from tamr_client.dataset._dataset import attributes, from_resource_id, NotFound diff --git a/tamr_client/dataset/_dataset.py b/tamr_client/dataset/_dataset.py index 1430f17a..8dbf15fa 100644 --- a/tamr_client/dataset/_dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -2,9 +2,12 @@ See https://docs.tamr.com/reference/dataset-models """ from copy import deepcopy +from dataclasses import replace +from typing import Tuple from tamr_client import response -from tamr_client._types import Dataset, Instance, JsonDict, Session, URL +from tamr_client._types import Attribute, Dataset, Instance, JsonDict, Session, URL +from tamr_client.attribute import _from_json as _attribute_from_json from tamr_client.exception import TamrClientException @@ -74,3 +77,28 @@ def _from_json(url: URL, data: JsonDict) -> Dataset: description=cp.get("description"), key_attribute_names=tuple(cp["keyAttributeNames"]), ) + + +def attributes(session: Session, dataset: Dataset) -> Tuple[Attribute, ...]: + """Get all attributes from a dataset + + Args: + dataset: Dataset containing the desired attributes + + Returns: + The attributes for the specified dataset + + Raises: + requests.HTTPError: If an HTTP error is encountered. + """ + attrs_url = replace(dataset.url, path=dataset.url.path + "/attributes") + r = session.get(str(attrs_url)) + attrs_json = response.successful(r).json() + + attrs = [] + for attr_json in attrs_json: + id = attr_json["name"] + attr_url = replace(attrs_url, path=attrs_url.path + f"/{id}") + attr = _attribute_from_json(attr_url, attr_json) + attrs.append(attr) + return tuple(attrs) diff --git a/tests/tamr_client/attribute/test_attribute.py b/tests/tamr_client/attribute/test_attribute.py index d34738d1..db6c079c 100644 --- a/tests/tamr_client/attribute/test_attribute.py +++ b/tests/tamr_client/attribute/test_attribute.py @@ -123,26 +123,6 @@ def test_create_reserved_attribute_name(): tc.attribute.create(s, dataset, name="clusterId", is_nullable=False) -@responses.activate -def test_from_dataset_all(): - s = fake.session() - dataset = fake.dataset() - - attrs_url = replace(dataset.url, path=dataset.url.path + "/attributes") - attrs_json = utils.load_json("attributes.json") - responses.add(responses.GET, str(attrs_url), json=attrs_json, status=204) - - attrs = tc.attribute.from_dataset_all(s, dataset) - - row_num = attrs[0] - assert row_num.name == "RowNum" - assert row_num.type == tc.attribute.type.STRING - - geom = attrs[1] - assert geom.name == "geom" - assert isinstance(geom.type, tc.attribute.type.Record) - - @responses.activate def test_create_attribute_exists(): s = fake.session() diff --git a/tests/tamr_client/dataset/test_dataset.py b/tests/tamr_client/dataset/test_dataset.py index 256b750f..65cce900 100644 --- a/tests/tamr_client/dataset/test_dataset.py +++ b/tests/tamr_client/dataset/test_dataset.py @@ -1,3 +1,5 @@ +from dataclasses import replace + import pytest import responses @@ -30,3 +32,23 @@ def test_from_resource_id_dataset_not_found(): with pytest.raises(tc.dataset.NotFound): tc.dataset.from_resource_id(s, instance, "1") + + +@responses.activate +def test_attributes(): + s = fake.session() + dataset = fake.dataset() + + attrs_url = replace(dataset.url, path=dataset.url.path + "/attributes") + attrs_json = utils.load_json("attributes.json") + responses.add(responses.GET, str(attrs_url), json=attrs_json, status=204) + + attrs = tc.dataset.attributes(s, dataset) + + row_num = attrs[0] + assert row_num.name == "RowNum" + assert row_num.type == tc.attribute.type.STRING + + geom = attrs[1] + assert geom.name == "geom" + assert isinstance(geom.type, tc.attribute.type.Record) From 1e7f3ec9dfc8fd80a481ef8ca9501ed9dce51596 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 6 Aug 2020 19:22:26 -0400 Subject: [PATCH 511/632] Update docs. --- docs/beta/attribute/attribute.rst | 1 - docs/beta/dataset/dataset.rst | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/beta/attribute/attribute.rst b/docs/beta/attribute/attribute.rst index d8a78025..50adc00d 100644 --- a/docs/beta/attribute/attribute.rst +++ b/docs/beta/attribute/attribute.rst @@ -4,7 +4,6 @@ Attribute .. autoclass:: tamr_client.Attribute .. autofunction:: tamr_client.attribute.from_resource_id -.. autofunction:: tamr_client.attribute.from_dataset_all .. autofunction:: tamr_client.attribute.to_json .. autofunction:: tamr_client.attribute.create .. autofunction:: tamr_client.attribute.update diff --git a/docs/beta/dataset/dataset.rst b/docs/beta/dataset/dataset.rst index e219c3ba..0df9bd26 100644 --- a/docs/beta/dataset/dataset.rst +++ b/docs/beta/dataset/dataset.rst @@ -4,6 +4,7 @@ Dataset .. autoclass:: tamr_client.Dataset .. autofunction:: tamr_client.dataset.from_resource_id +.. autofunction:: tamr_client.dataset.attributes Exceptions ---------- From 712a70591becc792639a2fd660d7b15909aad520 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 6 Aug 2020 19:23:36 -0400 Subject: [PATCH 512/632] Add option to pass --diff flag when running nox format dev task. --- noxfile.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/noxfile.py b/noxfile.py index bb9c28e5..9b59eb04 100644 --- a/noxfile.py +++ b/noxfile.py @@ -22,6 +22,8 @@ def format(session): session.run("poetry", "install", external=True) if "--fix" in session.posargs: session.run("black", ".") + elif "--diff" in session.posargs: + session.run("black", ".", "--diff") else: session.run("black", ".", "--check") From 3f53d8f835c6aaa171dc4f2c438d1822b5bf9a70 Mon Sep 17 00:00:00 2001 From: skalish Date: Mon, 10 Aug 2020 10:32:03 -0400 Subject: [PATCH 513/632] Update basic dataset testing. --- tests/tamr_client/dataset/test_dataset.py | 11 ++----- .../test_dataset/test_from_resource_id.json | 33 +++++++++++++++++++ ...st_from_resource_id_dataset_not_found.json | 11 +++++++ 3 files changed, 46 insertions(+), 9 deletions(-) create mode 100644 tests/tamr_client/fake_json/dataset/test_dataset/test_from_resource_id.json create mode 100644 tests/tamr_client/fake_json/dataset/test_dataset/test_from_resource_id_dataset_not_found.json diff --git a/tests/tamr_client/dataset/test_dataset.py b/tests/tamr_client/dataset/test_dataset.py index 65cce900..ed152004 100644 --- a/tests/tamr_client/dataset/test_dataset.py +++ b/tests/tamr_client/dataset/test_dataset.py @@ -7,29 +7,22 @@ from tests.tamr_client import fake, utils -@responses.activate +@fake.json def test_from_resource_id(): s = fake.session() instance = fake.instance() - dataset_json = utils.load_json("dataset.json") - url = tc.URL(path="datasets/1") - responses.add(responses.GET, str(url), json=dataset_json) - dataset = tc.dataset.from_resource_id(s, instance, "1") assert dataset.name == "dataset 1 name" assert dataset.description == "dataset 1 description" assert dataset.key_attribute_names == ("tamr_id",) -@responses.activate +@fake.json def test_from_resource_id_dataset_not_found(): s = fake.session() instance = fake.instance() - url = tc.URL(path="datasets/1") - responses.add(responses.GET, str(url), status=404) - with pytest.raises(tc.dataset.NotFound): tc.dataset.from_resource_id(s, instance, "1") diff --git a/tests/tamr_client/fake_json/dataset/test_dataset/test_from_resource_id.json b/tests/tamr_client/fake_json/dataset/test_dataset/test_from_resource_id.json new file mode 100644 index 00000000..2d7a3b2f --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataset/test_from_resource_id.json @@ -0,0 +1,33 @@ +[ + { + "request": { + "method": "GET", + "path": "datasets/1" + }, + "response": { + "json": { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "dataset 1 name", + "description": "dataset 1 description", + "version": "dataset 1 version", + "keyAttributeNames": [ + "tamr_id" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/dataset/test_dataset/test_from_resource_id_dataset_not_found.json b/tests/tamr_client/fake_json/dataset/test_dataset/test_from_resource_id_dataset_not_found.json new file mode 100644 index 00000000..fdc4349f --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataset/test_from_resource_id_dataset_not_found.json @@ -0,0 +1,11 @@ +[ + { + "request": { + "method": "GET", + "path": "datasets/1" + }, + "response": { + "status": 404 + } + } +] \ No newline at end of file From 82162f27d87a870ce1b4685dd3601f1d4a8091ed Mon Sep 17 00:00:00 2001 From: skalish Date: Mon, 10 Aug 2020 14:14:22 -0400 Subject: [PATCH 514/632] Update remaining dataset._dataset tests. --- tests/tamr_client/dataset/test_dataset.py | 11 +-- .../dataset/test_dataset/test_attributes.json | 79 +++++++++++++++++++ 2 files changed, 81 insertions(+), 9 deletions(-) create mode 100644 tests/tamr_client/fake_json/dataset/test_dataset/test_attributes.json diff --git a/tests/tamr_client/dataset/test_dataset.py b/tests/tamr_client/dataset/test_dataset.py index ed152004..726e4ad7 100644 --- a/tests/tamr_client/dataset/test_dataset.py +++ b/tests/tamr_client/dataset/test_dataset.py @@ -1,10 +1,7 @@ -from dataclasses import replace - import pytest -import responses import tamr_client as tc -from tests.tamr_client import fake, utils +from tests.tamr_client import fake @fake.json @@ -27,15 +24,11 @@ def test_from_resource_id_dataset_not_found(): tc.dataset.from_resource_id(s, instance, "1") -@responses.activate +@fake.json def test_attributes(): s = fake.session() dataset = fake.dataset() - attrs_url = replace(dataset.url, path=dataset.url.path + "/attributes") - attrs_json = utils.load_json("attributes.json") - responses.add(responses.GET, str(attrs_url), json=attrs_json, status=204) - attrs = tc.dataset.attributes(s, dataset) row_num = attrs[0] diff --git a/tests/tamr_client/fake_json/dataset/test_dataset/test_attributes.json b/tests/tamr_client/fake_json/dataset/test_dataset/test_attributes.json new file mode 100644 index 00000000..01022b72 --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataset/test_attributes.json @@ -0,0 +1,79 @@ +[ + { + "request": { + "method": "GET", + "path": "datasets/1/attributes" + }, + "response": { + "json": [ + { + "name": "RowNum", + "description": "Synthetic row number", + "type": { + "baseType": "STRING", + "attributes": [] + }, + "isNullable": false + }, + { + "name": "geom", + "description": "", + "type": { + "baseType": "RECORD", + "attributes": [ + { + "name": "point", + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "DOUBLE", + "attributes": [] + }, + "attributes": [] + }, + "isNullable": true + }, + { + "name": "lineString", + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "ARRAY", + "innerType": { + "baseType": "DOUBLE", + "attributes": [] + }, + "attributes": [] + }, + "attributes": [] + }, + "isNullable": true + }, + { + "name": "polygon", + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "ARRAY", + "innerType": { + "baseType": "ARRAY", + "innerType": { + "baseType": "DOUBLE", + "attributes": [] + }, + "attributes": [] + }, + "attributes": [] + }, + "attributes": [] + }, + "isNullable": true + } + ] + }, + "isNullable": false + } + ] + } + } +] \ No newline at end of file From 500a34310def8c626d15d77ab813db6acda5bde8 Mon Sep 17 00:00:00 2001 From: skalish Date: Mon, 10 Aug 2020 15:23:28 -0400 Subject: [PATCH 515/632] Update attribute tests to use fake.json instead of responses. --- tests/tamr_client/attribute/test_attribute.py | 38 +++++------- .../attribute/test_attribute/test_create.json | 59 +++++++++++++++++++ .../test_create_attribute_exists.json | 11 ++++ .../attribute/test_attribute/test_delete.json | 11 ++++ .../test_delete_attribute_not_found.json | 11 ++++ .../test_attribute/test_from_resource_id.json | 59 +++++++++++++++++++ ..._from_resource_id_attribute_not_found.json | 11 ++++ .../attribute/test_attribute/test_update.json | 19 ++++++ .../test_update_attribute_not_found.json | 11 ++++ 9 files changed, 206 insertions(+), 24 deletions(-) create mode 100644 tests/tamr_client/fake_json/attribute/test_attribute/test_create.json create mode 100644 tests/tamr_client/fake_json/attribute/test_attribute/test_create_attribute_exists.json create mode 100644 tests/tamr_client/fake_json/attribute/test_attribute/test_delete.json create mode 100644 tests/tamr_client/fake_json/attribute/test_attribute/test_delete_attribute_not_found.json create mode 100644 tests/tamr_client/fake_json/attribute/test_attribute/test_from_resource_id.json create mode 100644 tests/tamr_client/fake_json/attribute/test_attribute/test_from_resource_id_attribute_not_found.json create mode 100644 tests/tamr_client/fake_json/attribute/test_attribute/test_update.json create mode 100644 tests/tamr_client/fake_json/attribute/test_attribute/test_update_attribute_not_found.json diff --git a/tests/tamr_client/attribute/test_attribute.py b/tests/tamr_client/attribute/test_attribute.py index db6c079c..becca9c7 100644 --- a/tests/tamr_client/attribute/test_attribute.py +++ b/tests/tamr_client/attribute/test_attribute.py @@ -1,7 +1,6 @@ from dataclasses import replace import pytest -import responses import tamr_client as tc from tests.tamr_client import fake, utils @@ -30,7 +29,7 @@ def test_json(): assert attr == tc.attribute._from_json(url, tc.attribute.to_json(attr)) -@responses.activate +@fake.json def test_create(): s = fake.session() dataset = fake.dataset() @@ -46,10 +45,9 @@ def test_create(): ] ) - attrs_url = tc.URL(path=dataset.url.path + "/attributes") - url = replace(attrs_url, path=attrs_url.path + "/attr") + url = tc.URL(path=dataset.url.path + "/attributes/attr") attr_json = utils.load_json("attribute.json") - responses.add(responses.POST, str(attrs_url), json=attr_json) + attr = tc.attribute.create( s, dataset, @@ -61,7 +59,7 @@ def test_create(): assert attr == tc.attribute._from_json(url, attr_json) -@responses.activate +@fake.json def test_update(): s = fake.session() @@ -70,15 +68,15 @@ def test_update(): attr = tc.attribute._from_json(url, attr_json) updated_attr_json = utils.load_json("updated_attribute.json") - responses.add(responses.PUT, str(attr.url), json=updated_attr_json) + updated_attr = tc.attribute.update( - s, attr, description=updated_attr_json["description"] + s, attr, description="Synthetic row number updated" ) assert updated_attr == replace(attr, description=updated_attr_json["description"]) -@responses.activate +@fake.json def test_delete(): s = fake.session() @@ -86,31 +84,27 @@ def test_delete(): attr_json = utils.load_json("attributes.json")[0] attr = tc.attribute._from_json(url, attr_json) - responses.add(responses.DELETE, str(attr.url), status=204) tc.attribute.delete(s, attr) -@responses.activate +@fake.json def test_from_resource_id(): s = fake.session() dataset = fake.dataset() url = tc.URL(path=dataset.url.path + "/attributes/attr") attr_json = utils.load_json("attribute.json") - responses.add(responses.GET, str(url), json=attr_json) + attr = tc.attribute.from_resource_id(s, dataset, "attr") assert attr == tc.attribute._from_json(url, attr_json) -@responses.activate +@fake.json def test_from_resource_id_attribute_not_found(): s = fake.session() dataset = fake.dataset() - url = replace(dataset.url, path=dataset.url.path + "/attributes/attr") - - responses.add(responses.GET, str(url), status=404) with pytest.raises(tc.attribute.NotFound): tc.attribute.from_resource_id(s, dataset, "attr") @@ -123,18 +117,16 @@ def test_create_reserved_attribute_name(): tc.attribute.create(s, dataset, name="clusterId", is_nullable=False) -@responses.activate +@fake.json def test_create_attribute_exists(): s = fake.session() dataset = fake.dataset() - url = replace(dataset.url, path=dataset.url.path + "/attributes") - responses.add(responses.POST, str(url), status=409) with pytest.raises(tc.attribute.AlreadyExists): tc.attribute.create(s, dataset, name="attr", is_nullable=False) -@responses.activate +@fake.json def test_update_attribute_not_found(): s = fake.session() @@ -142,12 +134,11 @@ def test_update_attribute_not_found(): attr_json = utils.load_json("attributes.json")[0] attr = tc.attribute._from_json(url, attr_json) - responses.add(responses.PUT, str(attr.url), status=404) with pytest.raises(tc.attribute.NotFound): tc.attribute.update(s, attr) -@responses.activate +@fake.json def test_delete_attribute_not_found(): s = fake.session() @@ -155,6 +146,5 @@ def test_delete_attribute_not_found(): attr_json = utils.load_json("attributes.json")[0] attr = tc.attribute._from_json(url, attr_json) - responses.add(responses.PUT, str(attr.url), status=404) with pytest.raises(tc.attribute.NotFound): - attr = tc.attribute.update(s, attr) + tc.attribute.update(s, attr) diff --git a/tests/tamr_client/fake_json/attribute/test_attribute/test_create.json b/tests/tamr_client/fake_json/attribute/test_attribute/test_create.json new file mode 100644 index 00000000..02224cb5 --- /dev/null +++ b/tests/tamr_client/fake_json/attribute/test_attribute/test_create.json @@ -0,0 +1,59 @@ +[ + { + "request": { + "method": "POST", + "path": "datasets/1/attributes" + }, + "response": { + "json": { + "name": "attr", + "isNullable": false, + "type": { + "baseType": "RECORD", + "attributes": [ + { + "name": "0", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + }, + { + "name": "1", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + }, + { + "name": "2", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + }, + { + "name": "3", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + } + ] + } + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/attribute/test_attribute/test_create_attribute_exists.json b/tests/tamr_client/fake_json/attribute/test_attribute/test_create_attribute_exists.json new file mode 100644 index 00000000..38d9b1a9 --- /dev/null +++ b/tests/tamr_client/fake_json/attribute/test_attribute/test_create_attribute_exists.json @@ -0,0 +1,11 @@ +[ + { + "request": { + "method": "POST", + "path": "datasets/1/attributes" + }, + "response": { + "status": 409 + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/attribute/test_attribute/test_delete.json b/tests/tamr_client/fake_json/attribute/test_attribute/test_delete.json new file mode 100644 index 00000000..25ea6fa8 --- /dev/null +++ b/tests/tamr_client/fake_json/attribute/test_attribute/test_delete.json @@ -0,0 +1,11 @@ +[ + { + "request": { + "method": "DELETE", + "path": "datasets/1/attributes/RowNum" + }, + "response": { + "status": 204 + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/attribute/test_attribute/test_delete_attribute_not_found.json b/tests/tamr_client/fake_json/attribute/test_attribute/test_delete_attribute_not_found.json new file mode 100644 index 00000000..0b505578 --- /dev/null +++ b/tests/tamr_client/fake_json/attribute/test_attribute/test_delete_attribute_not_found.json @@ -0,0 +1,11 @@ +[ + { + "request": { + "method": "PUT", + "path": "datasets/1/attributes/RowNum" + }, + "response": { + "status": 404 + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/attribute/test_attribute/test_from_resource_id.json b/tests/tamr_client/fake_json/attribute/test_attribute/test_from_resource_id.json new file mode 100644 index 00000000..4958bd64 --- /dev/null +++ b/tests/tamr_client/fake_json/attribute/test_attribute/test_from_resource_id.json @@ -0,0 +1,59 @@ +[ + { + "request": { + "method": "GET", + "path": "datasets/1/attributes/attr" + }, + "response": { + "json": { + "name": "attr", + "isNullable": false, + "type": { + "baseType": "RECORD", + "attributes": [ + { + "name": "0", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + }, + { + "name": "1", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + }, + { + "name": "2", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + }, + { + "name": "3", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + } + ] + } + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/attribute/test_attribute/test_from_resource_id_attribute_not_found.json b/tests/tamr_client/fake_json/attribute/test_attribute/test_from_resource_id_attribute_not_found.json new file mode 100644 index 00000000..3ce31cb9 --- /dev/null +++ b/tests/tamr_client/fake_json/attribute/test_attribute/test_from_resource_id_attribute_not_found.json @@ -0,0 +1,11 @@ +[ + { + "request": { + "method": "GET", + "path": "datasets/1/attributes/attr" + }, + "response": { + "status": 404 + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/attribute/test_attribute/test_update.json b/tests/tamr_client/fake_json/attribute/test_attribute/test_update.json new file mode 100644 index 00000000..8bfe7ae6 --- /dev/null +++ b/tests/tamr_client/fake_json/attribute/test_attribute/test_update.json @@ -0,0 +1,19 @@ +[ + { + "request": { + "method": "PUT", + "path": "datasets/1/attributes/RowNum" + }, + "response": { + "json": { + "name": "RowNum", + "description": "Synthetic row number updated", + "type": { + "baseType": "STRING", + "attributes": [] + }, + "isNullable": false + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/attribute/test_attribute/test_update_attribute_not_found.json b/tests/tamr_client/fake_json/attribute/test_attribute/test_update_attribute_not_found.json new file mode 100644 index 00000000..0b505578 --- /dev/null +++ b/tests/tamr_client/fake_json/attribute/test_attribute/test_update_attribute_not_found.json @@ -0,0 +1,11 @@ +[ + { + "request": { + "method": "PUT", + "path": "datasets/1/attributes/RowNum" + }, + "response": { + "status": 404 + } + } +] \ No newline at end of file From 75647489d74da6e3d172adb3bfb4d948e259b83e Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 12 Aug 2020 16:36:24 -0400 Subject: [PATCH 516/632] Change attribute testing that uses fake.json to check against hard-coded values. --- tests/tamr_client/attribute/test_attribute.py | 53 +++++++++---------- tests/tamr_client/data/attribute.json | 49 ----------------- tests/tamr_client/data/attributes.json | 52 +++++++++++++++++- tests/tamr_client/data/updated_attribute.json | 9 ---- tests/tamr_client/fake.py | 10 ++++ .../test_delete_attribute_not_found.json | 2 +- 6 files changed, 86 insertions(+), 89 deletions(-) delete mode 100644 tests/tamr_client/data/attribute.json delete mode 100644 tests/tamr_client/data/updated_attribute.json diff --git a/tests/tamr_client/attribute/test_attribute.py b/tests/tamr_client/attribute/test_attribute.py index becca9c7..4dbede03 100644 --- a/tests/tamr_client/attribute/test_attribute.py +++ b/tests/tamr_client/attribute/test_attribute.py @@ -1,5 +1,3 @@ -from dataclasses import replace - import pytest import tamr_client as tc @@ -45,9 +43,6 @@ def test_create(): ] ) - url = tc.URL(path=dataset.url.path + "/attributes/attr") - attr_json = utils.load_json("attribute.json") - attr = tc.attribute.create( s, dataset, @@ -56,33 +51,28 @@ def test_create(): type=tc.attribute.type.Record(attributes=attrs), ) - assert attr == tc.attribute._from_json(url, attr_json) + assert attr.name == "attr" + assert not attr.is_nullable + assert isinstance(attr.type, tc.attribute.type.Record) + assert attr.type.attributes == attrs @fake.json def test_update(): s = fake.session() - - url = tc.URL(path="datasets/1/attributes/RowNum") - attr_json = utils.load_json("attributes.json")[0] - attr = tc.attribute._from_json(url, attr_json) - - updated_attr_json = utils.load_json("updated_attribute.json") + attr = fake.attribute() updated_attr = tc.attribute.update( s, attr, description="Synthetic row number updated" ) - assert updated_attr == replace(attr, description=updated_attr_json["description"]) + assert updated_attr.description == "Synthetic row number updated" @fake.json def test_delete(): s = fake.session() - - url = tc.URL(path="datasets/1/attributes/RowNum") - attr_json = utils.load_json("attributes.json")[0] - attr = tc.attribute._from_json(url, attr_json) + attr = fake.attribute() tc.attribute.delete(s, attr) @@ -92,12 +82,23 @@ def test_from_resource_id(): s = fake.session() dataset = fake.dataset() - url = tc.URL(path=dataset.url.path + "/attributes/attr") - attr_json = utils.load_json("attribute.json") + attrs = tuple( + [ + tc.SubAttribute( + name=str(i), + is_nullable=True, + type=tc.attribute.type.Array(tc.attribute.type.STRING), + ) + for i in range(4) + ] + ) attr = tc.attribute.from_resource_id(s, dataset, "attr") - assert attr == tc.attribute._from_json(url, attr_json) + assert attr.name == "attr" + assert not attr.is_nullable + assert isinstance(attr.type, tc.attribute.type.Record) + assert attr.type.attributes == attrs @fake.json @@ -129,10 +130,7 @@ def test_create_attribute_exists(): @fake.json def test_update_attribute_not_found(): s = fake.session() - - url = tc.URL(path="datasets/1/attributes/RowNum") - attr_json = utils.load_json("attributes.json")[0] - attr = tc.attribute._from_json(url, attr_json) + attr = fake.attribute() with pytest.raises(tc.attribute.NotFound): tc.attribute.update(s, attr) @@ -141,10 +139,7 @@ def test_update_attribute_not_found(): @fake.json def test_delete_attribute_not_found(): s = fake.session() - - url = tc.URL(path="datasets/1/attributes/RowNum") - attr_json = utils.load_json("attributes.json")[0] - attr = tc.attribute._from_json(url, attr_json) + attr = fake.attribute() with pytest.raises(tc.attribute.NotFound): - tc.attribute.update(s, attr) + tc.attribute.delete(s, attr) diff --git a/tests/tamr_client/data/attribute.json b/tests/tamr_client/data/attribute.json deleted file mode 100644 index 505e6dfb..00000000 --- a/tests/tamr_client/data/attribute.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "name": "attr", - "isNullable": false, - "type": { - "baseType": "RECORD", - "attributes": [ - { - "name": "0", - "isNullable": true, - "type": { - "baseType": "ARRAY", - "innerType": { - "baseType": "STRING" - } - } - }, - { - "name": "1", - "isNullable": true, - "type": { - "baseType": "ARRAY", - "innerType": { - "baseType": "STRING" - } - } - }, - { - "name": "2", - "isNullable": true, - "type": { - "baseType": "ARRAY", - "innerType": { - "baseType": "STRING" - } - } - }, - { - "name": "3", - "isNullable": true, - "type": { - "baseType": "ARRAY", - "innerType": { - "baseType": "STRING" - } - } - } - ] - } -} diff --git a/tests/tamr_client/data/attributes.json b/tests/tamr_client/data/attributes.json index 8401df16..5b42540b 100644 --- a/tests/tamr_client/data/attributes.json +++ b/tests/tamr_client/data/attributes.json @@ -65,5 +65,55 @@ ] }, "isNullable": false + }, + { + "name": "attr", + "description": "", + "isNullable": false, + "type": { + "baseType": "RECORD", + "attributes": [ + { + "name": "0", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + }, + { + "name": "1", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + }, + { + "name": "2", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + }, + { + "name": "3", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + } + ] + } } -] \ No newline at end of file +] diff --git a/tests/tamr_client/data/updated_attribute.json b/tests/tamr_client/data/updated_attribute.json deleted file mode 100644 index 6995bea7..00000000 --- a/tests/tamr_client/data/updated_attribute.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "RowNum", - "description": "Synthetic row number updated", - "type": { - "baseType": "STRING", - "attributes": [] - }, - "isNullable": false -} \ No newline at end of file diff --git a/tests/tamr_client/fake.py b/tests/tamr_client/fake.py index 9efa45a9..70dfeb24 100644 --- a/tests/tamr_client/fake.py +++ b/tests/tamr_client/fake.py @@ -101,3 +101,13 @@ def transforms() -> tc.Transformations: ], unified_scope=["//Comment\nSELECT *;"], ) + + +def attribute() -> tc.Attribute: + return tc.Attribute( + url=tc.URL(path="datasets/1/attributes/RowNum"), + name="RowNum", + type=tc.attribute.type.Array(tc.attribute.type.STRING), + description="Synthetic row number", + is_nullable=False, + ) diff --git a/tests/tamr_client/fake_json/attribute/test_attribute/test_delete_attribute_not_found.json b/tests/tamr_client/fake_json/attribute/test_attribute/test_delete_attribute_not_found.json index 0b505578..fb0be2ec 100644 --- a/tests/tamr_client/fake_json/attribute/test_attribute/test_delete_attribute_not_found.json +++ b/tests/tamr_client/fake_json/attribute/test_attribute/test_delete_attribute_not_found.json @@ -1,7 +1,7 @@ [ { "request": { - "method": "PUT", + "method": "DELETE", "path": "datasets/1/attributes/RowNum" }, "response": { From ac800e7c0b33bf43483e7dc534f1b6d5388483c2 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 12 Aug 2020 16:42:27 -0400 Subject: [PATCH 517/632] Update changelog. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8b5b45e..9f6b5c53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Added function to get operation from resource ID - [#421](https://github.com/Datatamer/tamr-client/pull/421) Added functions for getting and replacing the transformations of a projects via `tc.transformations.get_all()` and `tc.transformations.replace_all()` - Added new dataclasses `Transformations` and `InputTransformations` to support these functions + - Moved function `tc.attribute.from_dataset_all` to `tc.dataset.attributes` **NEW FEATURES** - [#383](https://github.com/Datatamer/tamr-client/issues/383) Now able to create an Operation from Job resource id From ffb0e9aab2af0380dcf6d1dc4b273e89a5866a28 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Thu, 13 Aug 2020 14:19:05 -0400 Subject: [PATCH 518/632] Add function to return tamr version --- tamr_client/instance.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tamr_client/instance.py b/tamr_client/instance.py index 10649878..69e95d53 100644 --- a/tamr_client/instance.py +++ b/tamr_client/instance.py @@ -1,4 +1,4 @@ -from tamr_client._types import Instance +from tamr_client._types import Instance, Session def origin(instance: Instance) -> str: @@ -10,3 +10,20 @@ def origin(instance: Instance) -> str: return f"{instance.protocol}://{instance.host}" else: return f"{instance.protocol}://{instance.host}:{instance.port}" + + +def version(session: Session, instance: Instance) -> str: + """Return the Tamr version for an instance. + + Args: + session: Tamr Session + instance: Tamr instance + + Returns: Version + + """ + return _version(session, instance)['version'] + + +def _version(session: Session, instance: Instance) -> dict: + return session.get(f"{origin(instance)}/api/versioned/service/version").json() \ No newline at end of file From 6563d95d8fa8ca5340c0e8bbc0e581a4261c0d44 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Thu, 13 Aug 2020 14:32:46 -0400 Subject: [PATCH 519/632] fix formatting --- tamr_client/instance.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tamr_client/instance.py b/tamr_client/instance.py index 69e95d53..f857a6e1 100644 --- a/tamr_client/instance.py +++ b/tamr_client/instance.py @@ -22,8 +22,8 @@ def version(session: Session, instance: Instance) -> str: Returns: Version """ - return _version(session, instance)['version'] + return _version(session, instance)["version"] def _version(session: Session, instance: Instance) -> dict: - return session.get(f"{origin(instance)}/api/versioned/service/version").json() \ No newline at end of file + return session.get(f"{origin(instance)}/api/versioned/service/version").json() From cfbd6d1ffaef692948a81108667914181ae1af27 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 13 Aug 2020 15:23:10 -0400 Subject: [PATCH 520/632] Refer to attribute.type.DEFAULT instead of full array string expression. --- tests/tamr_client/fake.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tamr_client/fake.py b/tests/tamr_client/fake.py index 70dfeb24..bf3da6fa 100644 --- a/tests/tamr_client/fake.py +++ b/tests/tamr_client/fake.py @@ -107,7 +107,7 @@ def attribute() -> tc.Attribute: return tc.Attribute( url=tc.URL(path="datasets/1/attributes/RowNum"), name="RowNum", - type=tc.attribute.type.Array(tc.attribute.type.STRING), + type=tc.attribute.type.DEFAULT, description="Synthetic row number", is_nullable=False, ) From e986b5acc68b5dd81745919f2038e6e230551f3a Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Thu, 13 Aug 2020 15:54:02 -0400 Subject: [PATCH 521/632] Removed hidden _version function --- tamr_client/instance.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tamr_client/instance.py b/tamr_client/instance.py index f857a6e1..04e331a4 100644 --- a/tamr_client/instance.py +++ b/tamr_client/instance.py @@ -22,8 +22,4 @@ def version(session: Session, instance: Instance) -> str: Returns: Version """ - return _version(session, instance)["version"] - - -def _version(session: Session, instance: Instance) -> dict: - return session.get(f"{origin(instance)}/api/versioned/service/version").json() + return session.get(f"{origin(instance)}/api/versioned/service/version").json()["version"] From ba2ed145c59a7eaf880c749c1df585245c5f74a2 Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Thu, 13 Aug 2020 19:58:27 -0400 Subject: [PATCH 522/632] Update tamr_client/instance.py added comment regarding endpoints Co-authored-by: Pedro Cattori --- tamr_client/instance.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tamr_client/instance.py b/tamr_client/instance.py index 04e331a4..01cc8cef 100644 --- a/tamr_client/instance.py +++ b/tamr_client/instance.py @@ -22,4 +22,5 @@ def version(session: Session, instance: Instance) -> str: Returns: Version """ + # Version endpoints are not themselves versioned by design, but they are stable so they are ok to use here. return session.get(f"{origin(instance)}/api/versioned/service/version").json()["version"] From 79bcdf2b488d667238daf3e2242a263886d642a7 Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Thu, 13 Aug 2020 19:59:36 -0400 Subject: [PATCH 523/632] Update instance.py --- tamr_client/instance.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tamr_client/instance.py b/tamr_client/instance.py index 01cc8cef..9f8f3d66 100644 --- a/tamr_client/instance.py +++ b/tamr_client/instance.py @@ -23,4 +23,6 @@ def version(session: Session, instance: Instance) -> str: """ # Version endpoints are not themselves versioned by design, but they are stable so they are ok to use here. - return session.get(f"{origin(instance)}/api/versioned/service/version").json()["version"] + return session.get(f"{origin(instance)}/api/versioned/service/version").json()[ + "version" + ] From eeaef3c0d8602f73ac5b8fa82f019f6539fbbb14 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Fri, 14 Aug 2020 09:58:19 -0400 Subject: [PATCH 524/632] Add test of instance.version() --- .../fake_json/test_instance/test_version.json | 18 ++++++++++++++++++ tests/tamr_client/test_instance.py | 9 ++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 tests/tamr_client/fake_json/test_instance/test_version.json diff --git a/tests/tamr_client/fake_json/test_instance/test_version.json b/tests/tamr_client/fake_json/test_instance/test_version.json new file mode 100644 index 00000000..6f6da1fd --- /dev/null +++ b/tests/tamr_client/fake_json/test_instance/test_version.json @@ -0,0 +1,18 @@ +[ + { + "request": { + "method": "GET", + "url": "http://localhost/api/versioned/service/version" + }, + "response": { + "json": { + "version": "2020.012.0", + "gitDescribe": "Element/release/1.0.3-26513-gab2085fb5d", + "gitCommitId": "ab2085fb5ddd626199f0e86c8a93561129629fad", + "gitCommitShort": "ab2085fb5d", + "gitCommitTime": "2020-06-18 03:28:36 PM UTC", + "buildTime": "2020-06-18 05:25:55 PM UTC" + } + } + } +] diff --git a/tests/tamr_client/test_instance.py b/tests/tamr_client/test_instance.py index a60c9b2c..682db176 100644 --- a/tests/tamr_client/test_instance.py +++ b/tests/tamr_client/test_instance.py @@ -1,5 +1,5 @@ import tamr_client as tc - +from tests.tamr_client import fake def test_instance_default(): instance = tc.Instance() @@ -19,3 +19,10 @@ def test_client_set_host(): def test_client_set_port(): instance = tc.Instance(port=9100) assert tc.instance.origin(instance) == "http://localhost:9100" + +@fake.json +def test_version(): + s = fake.session() + instance = fake.instance() + version = tc.instance.version(s,instance) + assert version == "2020.012.0" From 28616d8027016b8ba1fa8fdd7d5678b63d63f09f Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Fri, 14 Aug 2020 10:09:10 -0400 Subject: [PATCH 525/632] Fix test lint/format --- tests/tamr_client/test_instance.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/tamr_client/test_instance.py b/tests/tamr_client/test_instance.py index 682db176..3547f301 100644 --- a/tests/tamr_client/test_instance.py +++ b/tests/tamr_client/test_instance.py @@ -1,6 +1,7 @@ import tamr_client as tc from tests.tamr_client import fake + def test_instance_default(): instance = tc.Instance() assert tc.instance.origin(instance) == "http://localhost" @@ -20,9 +21,10 @@ def test_client_set_port(): instance = tc.Instance(port=9100) assert tc.instance.origin(instance) == "http://localhost:9100" + @fake.json def test_version(): s = fake.session() instance = fake.instance() - version = tc.instance.version(s,instance) + version = tc.instance.version(s, instance) assert version == "2020.012.0" From 6037de02855dfee0652e7f9151e8332386f791e9 Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Fri, 14 Aug 2020 10:17:47 -0400 Subject: [PATCH 526/632] Update CHANGELOG.md --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8b5b45e..7094daad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ Important: Do not use BETA features for production workflows. - Added function to get operation from resource ID - [#421](https://github.com/Datatamer/tamr-client/pull/421) Added functions for getting and replacing the transformations of a projects via `tc.transformations.get_all()` and `tc.transformations.replace_all()` - - Added new dataclasses `Transformations` and `InputTransformations` to support these functions + - Added new dataclasses `Transformations` and `InputTransformations` to support these functions + - Added `tc.instance.version` function to get Tamr Version **NEW FEATURES** - [#383](https://github.com/Datatamer/tamr-client/issues/383) Now able to create an Operation from Job resource id From d8644c8b6c66b57a607521cc99ef5a45049687d8 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Fri, 14 Aug 2020 10:27:12 -0400 Subject: [PATCH 527/632] updated docs --- docs/beta/instance.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/beta/instance.rst b/docs/beta/instance.rst index 339d21a6..1892274a 100644 --- a/docs/beta/instance.rst +++ b/docs/beta/instance.rst @@ -4,3 +4,5 @@ Instance .. autoclass:: tamr_client.Instance .. autofunction:: tamr_client.instance.origin + +.. autofunction:: tamr_client.instance.version \ No newline at end of file From ceea755fe50f53f5c0253b7d450b3bc9d1c00dbc Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Fri, 14 Aug 2020 10:37:07 -0400 Subject: [PATCH 528/632] fix errant backspace in changelog.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7094daad..ed5b0f1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ Important: Do not use BETA features for production workflows. - Added function to get operation from resource ID - [#421](https://github.com/Datatamer/tamr-client/pull/421) Added functions for getting and replacing the transformations of a projects via `tc.transformations.get_all()` and `tc.transformations.replace_all()` - - Added new dataclasses `Transformations` and `InputTransformations` to support these functions + - Added new dataclasses `Transformations` and `InputTransformations` to support these functions - Added `tc.instance.version` function to get Tamr Version **NEW FEATURES** From e46623937353db0ab214e478687392103e7246b1 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 14 Aug 2020 14:34:06 -0400 Subject: [PATCH 529/632] Introduce Architectural Decision Records (ADRs) --- .adr-dir | 1 + adr/0001-record-architecture-decisions.md | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 .adr-dir create mode 100644 adr/0001-record-architecture-decisions.md diff --git a/.adr-dir b/.adr-dir new file mode 100644 index 00000000..548f973c --- /dev/null +++ b/.adr-dir @@ -0,0 +1 @@ +docs/contributor-guide/adr/ diff --git a/adr/0001-record-architecture-decisions.md b/adr/0001-record-architecture-decisions.md new file mode 100644 index 00000000..8833a49e --- /dev/null +++ b/adr/0001-record-architecture-decisions.md @@ -0,0 +1,19 @@ +# 1. Record architecture decisions + +Date: 2020-08-14 + +## Status + +Accepted + +## Context + +We need to record the architectural decisions made on this project. + +## Decision + +We will use Architecture Decision Records, as [described by Michael Nygard](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions). + +## Consequences + +See Michael Nygard's article, linked above. For a lightweight ADR toolset, see Nat Pryce's [adr-tools](https://github.com/npryce/adr-tools). From 87f82ec80119629598334d7c82763fa0f006f02e Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 14 Aug 2020 14:34:28 -0400 Subject: [PATCH 530/632] Backfill historical ADRs --- docs/contributor-guide.md | 2 +- .../0001-record-architecture-decisions.md | 0 .../adr/0002-linting-and-formatting.md | 31 +++++++++ .../adr/0003-reproducibility.md | 31 +++++++++ .../adr/0004-documentation-and-docstrings.md | 39 +++++++++++ .../adr/0005-composable-functions.md | 69 +++++++++++++++++++ .../adr/0006-type-checking.md | 22 ++++++ .../adr/0007-tamr-client-package.md | 33 +++++++++ .../adr/0008-standardized-imports.md | 47 +++++++++++++ .../adr/0009-separate-types-and-functions.md | 36 ++++++++++ docs/contributor-guide/adrs.md | 22 ++++++ docs/contributor-guide/style-guide.md | 20 ------ 12 files changed, 331 insertions(+), 21 deletions(-) rename {adr => docs/contributor-guide/adr}/0001-record-architecture-decisions.md (100%) create mode 100644 docs/contributor-guide/adr/0002-linting-and-formatting.md create mode 100644 docs/contributor-guide/adr/0003-reproducibility.md create mode 100644 docs/contributor-guide/adr/0004-documentation-and-docstrings.md create mode 100644 docs/contributor-guide/adr/0005-composable-functions.md create mode 100644 docs/contributor-guide/adr/0006-type-checking.md create mode 100644 docs/contributor-guide/adr/0007-tamr-client-package.md create mode 100644 docs/contributor-guide/adr/0008-standardized-imports.md create mode 100644 docs/contributor-guide/adr/0009-separate-types-and-functions.md create mode 100644 docs/contributor-guide/adrs.md delete mode 100644 docs/contributor-guide/style-guide.md diff --git a/docs/contributor-guide.md b/docs/contributor-guide.md index 9b8b0c5f..5dfafed6 100644 --- a/docs/contributor-guide.md +++ b/docs/contributor-guide.md @@ -24,7 +24,7 @@ Submit feature requests as [Github issues](https://github.com/Datatamer/tamr-cli * [Install the codebase](contributor-guide/install) * [Run dev tasks](contributor-guide/dev-tasks) * [Configure your text editor](contributor-guide/text-editor) -* [Read the style guide](contributor-guide/style-guide) +* [Read the ADRs](contributor-guide/adrs) * [How to write tests](contributor-guide/how-to-write-tests) * [Submit a pull request](contributor-guide/pull-request) diff --git a/adr/0001-record-architecture-decisions.md b/docs/contributor-guide/adr/0001-record-architecture-decisions.md similarity index 100% rename from adr/0001-record-architecture-decisions.md rename to docs/contributor-guide/adr/0001-record-architecture-decisions.md diff --git a/docs/contributor-guide/adr/0002-linting-and-formatting.md b/docs/contributor-guide/adr/0002-linting-and-formatting.md new file mode 100644 index 00000000..d6f744b4 --- /dev/null +++ b/docs/contributor-guide/adr/0002-linting-and-formatting.md @@ -0,0 +1,31 @@ +# 2. Linting and formatting + +Date: 2019-01-14 + +## Status + +Accepted + +## Context + +Inconsistent code formatting slows down development and the review process. + +Code should be linted for things like: +- unused imports and variables +- consistent import order + +Code formatting should be done automatically or programmatically, taking the burden off of reviewers. + +## Decision + +For linting, use [flake8](https://flake8.pycqa.org/en/latest/) and [flake8-import-order](https://github.com/PyCQA/flake8-import-order). + +For formatting, use [black](https://github.com/psf/black). + +## Consequences + +All linting and formatting are enforced programmatically. + +Most linting and formatting errors can be autofixed. + +Text editors and IDEs are able to integrate with our linting and formattings tools to automatically fix (most) errors on save. \ No newline at end of file diff --git a/docs/contributor-guide/adr/0003-reproducibility.md b/docs/contributor-guide/adr/0003-reproducibility.md new file mode 100644 index 00000000..be3245ff --- /dev/null +++ b/docs/contributor-guide/adr/0003-reproducibility.md @@ -0,0 +1,31 @@ +# 3. Reproducibility + +Date: 2019-06-05 + +## Status + +Accepted + +## Context + +Reproducing results from a program is challenging when operating systems, language versions, and dependency versions can vary. + +For this codebase, we will focus on consistent Python versions and dependency versions. + +## Decision + +Manage multiple Python versions via [pyenv](https://github.com/pyenv/pyenv). + +Manage dependencies via [poetry](https://python-poetry.org/). + +Define tests via [nox](https://nox.thea.codes/en/stable/). + +Run tests in automation/CI via [Github Actions](https://github.com/features/actions). + +## Consequences + +This solution lets us: +- keep track of [abstract *and* concrete versions](https://caremad.io/posts/2013/07/setup-vs-requirement/) for dependencies (think `.lock` file) +- locally test against multiple Python versions +- run the same tests locally as we do in [Continuous Integration](https://en.wikipedia.org/wiki/Continuous_integration) (CI) +- easily view CI test results within the review context diff --git a/docs/contributor-guide/adr/0004-documentation-and-docstrings.md b/docs/contributor-guide/adr/0004-documentation-and-docstrings.md new file mode 100644 index 00000000..202d43a3 --- /dev/null +++ b/docs/contributor-guide/adr/0004-documentation-and-docstrings.md @@ -0,0 +1,39 @@ +# 4. Documentation and docstrings + +Date: 2019-10-03 + +## Status + +Accepted + +## Context + +Documentation can take four forms: +1. Explanation +2. Tutorial +3. How-to +4. Reference + +We need a way to author and host prosey documentation and generate reference docs based on source code. + +## Decision + +Doc compilation will be done via [sphinx](https://www.sphinx-doc.org/en/master/). + +Prosey documentation (1-3) via [recommonmark](https://github.com/readthedocs/recommonmark). + +Reference documentation (4) will be generated based on type annotations and docstrings via: +- Automatic docs based on docstrings via [sphinx-autodoc](https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html), [sphinx-autodoc-typehints](https://github.com/agronholm/sphinx-autodoc-typehints) +- Google-style docstrings via [napoleon](https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html) +- Hosting on [ReadTheDocs](https://readthedocs.org/) (RTD) +- Build docs in CI and fail on errors or warnings. + +## Consequences + +Prosey documentation can be written in Markdown (.md), which is more familiar to our contributors than .rst format. + +Reference doc generation makes docs more maintainable and consistent with actual code. + +Google-style docstrings are easier to read than sphinx-style docstrings. + +RTD natively compiles documentation using sphinx and simultaneously hosts docs at each version. \ No newline at end of file diff --git a/docs/contributor-guide/adr/0005-composable-functions.md b/docs/contributor-guide/adr/0005-composable-functions.md new file mode 100644 index 00000000..3fc005f9 --- /dev/null +++ b/docs/contributor-guide/adr/0005-composable-functions.md @@ -0,0 +1,69 @@ +# 5. Composable functions + +Date: 2019-11-01 + +## Status + +Accepted + +## Context + +We need a reasonable tradeoff between ease-of-use and maintainability. + +Specifically, we need composable, combinable units that can be improved independently. + +### Approach 1: Classes + Methods + +One approach is to embrace Object-Oriented Programming (OOP) with fluent interfaces (i.e. method chaining): + +```python +project + .create(...) + .update(...) + .delete(...) +``` + +Characteristics: +- Ease-of-use is maximized, but this requires each method to `return self`. +- Also, this approach implies that if a function can be called with X different object types, +each of those object types should have a corresponding method that applies that functionality and then `return self`. + +How to enforce these characteristics? + +Any solution will be a tax on maintainability, as code that adheres to these characteristics will include many non-semantic lines simply going through the motions of `return self` and copying function usage into dedicated methods for each class. + +### Approach 2: Types + Functions + +Another approach is to embrace a functional programming style: simple types and functions (no methods). + +Usage is not as terse as for OOP: + +```python +p = tc.project.create(...) +u = tc.project.update(p, ...) +d = tc.project.delete(p, ...) +``` + +Characteristics: +- Ease-of-use is not optimized, but still reasonable. + - With tab-completion, ease-of-use is comparable to OOP. +- Each type can be made immutable +- Each function can be made pure +- Functionality can be shared by calling the same function in user-land, not copying function calls in contributor-land. + +## Decision + +Use `@dataclass(frozen=True)` to model types and plain Python modules and functions to capture business logic. + +## Consequences + +Immutable types and pure functions make the code much easier to reason about, +drastically cutting down the time to ramp up and debug. + +Functions are easily composable without accumulating undesired side-effects, unlike methods. + +Note that not all types and functions *have* to be immutable and pure, +but immutable types and pure functions should be the default. + +If there are good reasons to make exceptions, we can do so, but we should include comments to explain why that exception was made. + diff --git a/docs/contributor-guide/adr/0006-type-checking.md b/docs/contributor-guide/adr/0006-type-checking.md new file mode 100644 index 00000000..72089494 --- /dev/null +++ b/docs/contributor-guide/adr/0006-type-checking.md @@ -0,0 +1,22 @@ +# 6. Type-checking + +Date: 2020-01-29 + +## Status + +Accepted + +## Context + +Static type-checking is available for Python, making us of the type annotations already in the codebase. + +## Decision + +Type-check via [mypy](http://mypy-lang.org/). + +## Consequences + +Testing is still important, but type checking helps to eliminate bugs via static checking, +even for parts of the code not exercised during tests. + +Additionally, type-checking relies on our type annotations, ensuring that the annotations are correct and complete. diff --git a/docs/contributor-guide/adr/0007-tamr-client-package.md b/docs/contributor-guide/adr/0007-tamr-client-package.md new file mode 100644 index 00000000..15b91c21 --- /dev/null +++ b/docs/contributor-guide/adr/0007-tamr-client-package.md @@ -0,0 +1,33 @@ +# 7. tamr_client package + +Date: 2020-04-03 + +## Status + +Accepted + +## Context + +We have an existing userbase that relies on `tamr_unify_client` and cannot painlessly make backwards-incompatible changes. + +But, we want to rearchitect this codebase as a [library of composable functions](/contributor-guide/adr/0005-composable-functions). + +## Decision + +Implement rearchitected design as a new package named `tamr_client`. + +Require the `TAMR_CLIENT_BETA=1` feature flag for `tamr_client` package usage. + +Warn users who attempt to use `tamr_client` package to opt-in if they want to beta test the new design. + +## Consequences + +Continue to support `tamr_unify_client`, but any new functionality: +- must be included in `tamr_client` +- may be included in `tamr_unify_client` + +Users are required to explicitly opt-in to new features, +preserving backward compatiblitiy for current users. + +Once we reach feature parity with `tamr_unify_client`, +we can undergo a deprecation cycle and subsequently remove `tamr_unify_client. diff --git a/docs/contributor-guide/adr/0008-standardized-imports.md b/docs/contributor-guide/adr/0008-standardized-imports.md new file mode 100644 index 00000000..5757a5fd --- /dev/null +++ b/docs/contributor-guide/adr/0008-standardized-imports.md @@ -0,0 +1,47 @@ +# 8. Standardized imports + +Date: 2020-06-01 + +## Status + +Accepted + +## Context + +Python has many ways of importing: + +```python +# option 1: import module + +# option 1.a +import foo.bar.bazaar as baz +baz.do_the_thing() + +# option 1.b +from foo.bar import bazaar as baz +baz.do_the_thing() + +# option 2: import value +from foo.bar.bazaar import do_the_thing +do_the_thing() +``` + +Not to mention that each of these styles may be done with relative imports (replacing `foo.bar` with `.bar` if the `bar` package is a sibling). + +Confusingly, Option 1.a and Option 1.b are _conceptually_ the same, but mechanically there are [subtle differences](https://stackoverflow.com/questions/24807434/imports-in-init-py-and-import-as-statement/24968941#24968941). + + +## Decision + +Imports within `tamr_client`: +- Must import statements for modules, classes, and exceptions +- Must `from foo import bar` instead of `import foo.bar as bar` +- Must not import functions directly. Instead import the containing module and use `module.function(...)` +- Must not use relative imports. Use absolute imports instead. + +## Consequences + +Standardized import style helps linter correctly order imports. + +Choosing import styles is a syntactic choice without semantic meaning. +Removing this choice should speed up development and review. \ No newline at end of file diff --git a/docs/contributor-guide/adr/0009-separate-types-and-functions.md b/docs/contributor-guide/adr/0009-separate-types-and-functions.md new file mode 100644 index 00000000..d7be6611 --- /dev/null +++ b/docs/contributor-guide/adr/0009-separate-types-and-functions.md @@ -0,0 +1,36 @@ +# 9. Separate types and functions + +Date: 2020-06-29 + +## Status + +Accepted + +## Context + +Code must be organized to be compatible with: +- Static type-checking via [mypy](https://github.com/python/mypy) +- Runtime execution during normal usage and running tests via [pytest](https://docs.pytest.org/en/stable/) +- Static doc generation via [sphinx-autodoc-typehints](https://github.com/agronholm/sphinx-autodoc-typehints) + +Additionally: +- Functions should be able to refer to any type +- Most types depend on other types non-recursively, but some types (e.g. `SubAttribute` and `AttributeType`) do depend on each other recursively / cyclically. + +## Decision + +Put types (`@dataclass(frozen=True)`) into the `_types` module +and have all function modules depend on the `_types` module to define their inputs and outputs. + +## Consequences + +Separating types into a `_types` module (e.g. `tc.Project` is an alias for `tc._types.project.Project`) +and functions into namespaced modules (e.g. `tc.project` is a module containing project-specific utilities) +allows all of our tooling to run successfully. + +Also, splitting up types and functions means that we can author a function like `tc.dataset.attributes` in the `tc.dataset` module +while still having the `tc.attribute` module depend on `tc.Dataset` type. + +Finally, for the rare cases where cyclical dependencies for types are unavoidable, +we can use [typing.TYPE_CHECKING](https://docs.python.org/3/library/typing.html#typing.TYPE_CHECKING) since `mypy` and Python are smart enough to resolve these cyclical correctly via [forward references](https://www.python.org/dev/peps/pep-0484/#forward-references). + diff --git a/docs/contributor-guide/adrs.md b/docs/contributor-guide/adrs.md new file mode 100644 index 00000000..ca8dabd5 --- /dev/null +++ b/docs/contributor-guide/adrs.md @@ -0,0 +1,22 @@ +# Architectural Decision Records + +Important architectural decisions are logged as Architectural Decision Records (ADRs) +and are housed here. + +For more on ADRs, see: +- [Why write ADRs](https://github.blog/2020-08-13-why-write-adrs/) +- [Earn future maintainers esteem by writing simple ADRs](https://understandlegacycode.com/blog/earn-maintainers-esteem-with-adrs/) + +To author new ADRs, we recommend [adr-tools](https://github.com/npryce/adr-tools). + +## ADRs + +* [Record architecture decisions](/contributor-guide/adr/0001-record-architecture-decisions) +* [Linting and formatting](/contributor-guide/adr/0002-linting-and-formatting) +* [Reproducibility](/contributor-guide/adr/0003-reproducibility) +* [Documentation and docstrings](/contributor-guide/adr/0004-documentation-and-docstrings) +* [Composable functions](/contributor-guide/adr/0005-composable-functions) +* [Type checking](/contributor-guide/adr/0006-type-checking) +* [tamr_client package](/contributor-guide/adr/0007-tamr-client-package) +* [Standardized imports](/contributor-guide/adr/0008-standardized-imports) +* [Separate types and functions](/contributor-guide/adr/0009-separate-types-and-functions) \ No newline at end of file diff --git a/docs/contributor-guide/style-guide.md b/docs/contributor-guide/style-guide.md deleted file mode 100644 index 883ef9e8..00000000 --- a/docs/contributor-guide/style-guide.md +++ /dev/null @@ -1,20 +0,0 @@ -# Style Guide - -### Formatting -Code should generally conform to the [PEP8 style guidelines](https://www.python.org/dev/peps/pep-0008/). - * [Flake8](https://flake8.pycqa.org/en/latest/) is a linter to help check that code is aligned with these formatting requirements - * [Black](https://black.readthedocs.io/en/stable/) is a formatter that can be used to automatically reformat code to resolve many (but not all) formatting issues - * For details on using these tools, see [the dev tasks guide](dev-tasks) - -### Structure -* Classes with methods should be avoided in favor of simple [dataclasses](https://docs.python.org/3/library/dataclasses.html) and functions -* Types (i.e. `dataclass`es) should be within the `_types` package. Separating types and functions into different packages helps keep type resolution simple so that all of our tools (`mypy`, `sphinx`, `pytest`) run correctly. - -### Google-style docstrings -All functions and class definitions should use [Google-style docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) and be annotated with [type hints](https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html#type-annotations). - -### Internal Imports -When importing from within `tamr-client`: -* Use import statements for modules, classes, and exceptions -* Never import functions directly. Instead import the containing module and use `module.function` -* Use `from foo import bar` instead of `import foo.bar as bar` From 8d9d006ebc0fdbe19fa45e31f901483db7752a90 Mon Sep 17 00:00:00 2001 From: Afsana Afzal Date: Fri, 14 Aug 2020 16:00:42 -0400 Subject: [PATCH 531/632] Create projects in Tamr --- CHANGELOG.md | 1 + docs/beta.md | 1 + docs/beta/categorization.md | 2 +- docs/beta/categorization/project.rst | 1 + docs/beta/mastering/project.rst | 2 + docs/beta/schema_mapping.md | 3 ++ docs/beta/schema_mapping/project.rst | 6 +++ tamr_client/__init__.py | 2 + tamr_client/_types/__init__.py | 7 ++- tamr_client/_types/project.py | 23 ++++++-- tamr_client/categorization/project.py | 42 ++++++++++++++- tamr_client/mastering/project.py | 47 ++++++++++++++++- tamr_client/project.py | 72 ++++++++++++++++++++++++-- tamr_client/schema_mapping/__init__.py | 5 ++ tamr_client/schema_mapping/project.py | 58 +++++++++++++++++++++ 15 files changed, 261 insertions(+), 11 deletions(-) create mode 100644 docs/beta/schema_mapping.md create mode 100644 docs/beta/schema_mapping/project.rst create mode 100644 tamr_client/schema_mapping/__init__.py create mode 100644 tamr_client/schema_mapping/project.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 908796ce..386caacf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ **NEW FEATURES** - [#383](https://github.com/Datatamer/tamr-client/issues/383) Now able to create an Operation from Job resource id - [#425](https://github.com/Datatamer/tamr-client/pull/425) Now able to get, update and delete manual labels for Categorization projects + - [#435](https://github.com/Datatamer/tamr-client/pull/435) Now able to create projects of the following type in Tamr: Categorization, Mastering, Schema Mapping ## 0.12.0 **BETA** diff --git a/docs/beta.md b/docs/beta.md index 28560c30..e1ad360e 100644 --- a/docs/beta.md +++ b/docs/beta.md @@ -14,6 +14,7 @@ * [Operation](beta/operation) * [Primary Key](beta/primary_key) * [Project](beta/project) + * [Schema Mapping](beta/schema_mapping) * [Transformations](beta/transformations) * [Response](beta/response) * [Session](beta/session) diff --git a/docs/beta/categorization.md b/docs/beta/categorization.md index c9347aa6..0a56c07c 100644 --- a/docs/beta/categorization.md +++ b/docs/beta/categorization.md @@ -1,3 +1,3 @@ -# Categoriation +# Categorization * [Project](/beta/categorization/project) diff --git a/docs/beta/categorization/project.rst b/docs/beta/categorization/project.rst index dc574180..9b57beef 100644 --- a/docs/beta/categorization/project.rst +++ b/docs/beta/categorization/project.rst @@ -3,4 +3,5 @@ Categorization Project .. autoclass:: tamr_client.CategorizationProject +.. autofunction:: tamr_client.categorization.project.create .. autofunction:: tamr_client.categorization.project.manual_labels \ No newline at end of file diff --git a/docs/beta/mastering/project.rst b/docs/beta/mastering/project.rst index cc73158b..e699d61d 100644 --- a/docs/beta/mastering/project.rst +++ b/docs/beta/mastering/project.rst @@ -2,3 +2,5 @@ Mastering Project ================= .. autoclass:: tamr_client.MasteringProject + +.. autofunction:: tamr_client.mastering.project.create \ No newline at end of file diff --git a/docs/beta/schema_mapping.md b/docs/beta/schema_mapping.md new file mode 100644 index 00000000..663695d6 --- /dev/null +++ b/docs/beta/schema_mapping.md @@ -0,0 +1,3 @@ +# Schema Mapping + + * [Project](/beta/schema_mapping/project) diff --git a/docs/beta/schema_mapping/project.rst b/docs/beta/schema_mapping/project.rst new file mode 100644 index 00000000..2491745a --- /dev/null +++ b/docs/beta/schema_mapping/project.rst @@ -0,0 +1,6 @@ +Schema Mapping Project +====================== + +.. autoclass:: tamr_client.SchemaMappingProject + +.. autofunction:: tamr_client.schema_mapping.project.create \ No newline at end of file diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index f6e6f5cb..8913c914 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -27,6 +27,7 @@ MasteringProject, Operation, Project, + SchemaMappingProject, Session, SubAttribute, Transformations, @@ -47,6 +48,7 @@ from tamr_client import primary_key from tamr_client import project from tamr_client import response +from tamr_client import schema_mapping from tamr_client import session from tamr_client import transformations from tamr_client.dataset import dataframe diff --git a/tamr_client/_types/__init__.py b/tamr_client/_types/__init__.py index 203daa69..36106171 100644 --- a/tamr_client/_types/__init__.py +++ b/tamr_client/_types/__init__.py @@ -20,7 +20,12 @@ from tamr_client._types.instance import Instance from tamr_client._types.json import JsonDict from tamr_client._types.operation import Operation -from tamr_client._types.project import CategorizationProject, MasteringProject, Project +from tamr_client._types.project import ( + CategorizationProject, + MasteringProject, + Project, + SchemaMappingProject, +) from tamr_client._types.session import Session from tamr_client._types.transformations import InputTransformation, Transformations from tamr_client._types.url import URL diff --git a/tamr_client/_types/project.py b/tamr_client/_types/project.py index d382684f..c7be346f 100644 --- a/tamr_client/_types/project.py +++ b/tamr_client/_types/project.py @@ -4,6 +4,23 @@ from tamr_client._types.url import URL +@dataclass(frozen=True) +class CategorizationProject: + """A Tamr Categorization project + + See https://docs.tamr.com/reference/the-project-object + + Args: + url + name + description + """ + + url: URL + name: str + description: Optional[str] = None + + @dataclass(frozen=True) class MasteringProject: """A Tamr Mastering project @@ -22,8 +39,8 @@ class MasteringProject: @dataclass(frozen=True) -class CategorizationProject: - """A Tamr Categorization project +class SchemaMappingProject: + """A Tamr Schema Mapping project See https://docs.tamr.com/reference/the-project-object @@ -38,4 +55,4 @@ class CategorizationProject: description: Optional[str] = None -Project = Union[MasteringProject, CategorizationProject] +Project = Union[CategorizationProject, MasteringProject, SchemaMappingProject] diff --git a/tamr_client/categorization/project.py b/tamr_client/categorization/project.py index 46c8e5c4..cdfa82a3 100644 --- a/tamr_client/categorization/project.py +++ b/tamr_client/categorization/project.py @@ -1,8 +1,12 @@ +from typing import Optional + +from tamr_client import project from tamr_client._types import ( CategorizationProject, Dataset, Instance, JsonDict, + Project, Session, URL, ) @@ -21,10 +25,46 @@ def _from_json(url: URL, data: JsonDict) -> CategorizationProject: ) +def create( + session: Session, + instance: Instance, + name: str, + description: Optional[str] = None, + external_id: Optional[str] = None, + unified_dataset_name: Optional[str] = None, +) -> Project: + """Create a Categorization project in Tamr. + + Args: + instance: Tamr instance + name: Project name + description: Project description + external_id: External ID of the project + unified_dataset_name: Unified dataset name. If None, will be set to project name + _'unified_dataset' + + Returns: + Project created in Tamr + + Raises: + AlreadyExists: If a project with these specifications already exists + requests.HTTPError: If any other HTTP error is encountered + """ + return project._create( + session=session, + instance=instance, + name=name, + project_type="CATEGORIZATION", + description=description, + external_id=external_id, + unified_dataset_name=unified_dataset_name, + ) + + def manual_labels( session: Session, instance: Instance, project: CategorizationProject ) -> Dataset: - """Get manual labels from a Categorization project + """Get manual labels from a Categorization project. + Args: instance: Tamr instance containing project project: Tamr project containing labels diff --git a/tamr_client/mastering/project.py b/tamr_client/mastering/project.py index ea8300fc..933624d7 100644 --- a/tamr_client/mastering/project.py +++ b/tamr_client/mastering/project.py @@ -1,4 +1,14 @@ -from tamr_client._types import JsonDict, MasteringProject, URL +from typing import Optional + +from tamr_client import project +from tamr_client._types import ( + Instance, + JsonDict, + MasteringProject, + Project, + Session, + URL, +) def _from_json(url: URL, data: JsonDict) -> MasteringProject: @@ -9,3 +19,38 @@ def _from_json(url: URL, data: JsonDict) -> MasteringProject: data: Project JSON data from Tamr server """ return MasteringProject(url, name=data["name"], description=data.get("description")) + + +def create( + session: Session, + instance: Instance, + name: str, + description: Optional[str] = None, + external_id: Optional[str] = None, + unified_dataset_name: Optional[str] = None, +) -> Project: + """Create a Mastering project in Tamr. + + Args: + instance: Tamr instance + name: Project name + description: Project description + external_id: External ID of the project + unified_dataset_name: Unified dataset name. If None, will be set to project name + _'unified_dataset' + + Returns: + Project created in Tamr + + Raises: + AlreadyExists: If a project with these specifications already exists. + requests.HTTPError: If any other HTTP error is encountered. + """ + return project._create( + session=session, + instance=instance, + name=name, + project_type="DEDUP", + description=description, + external_id=external_id, + unified_dataset_name=unified_dataset_name, + ) diff --git a/tamr_client/project.py b/tamr_client/project.py index 8d5ae307..55d89170 100644 --- a/tamr_client/project.py +++ b/tamr_client/project.py @@ -1,8 +1,11 @@ +from typing import Optional + from tamr_client import response from tamr_client._types import Instance, JsonDict, Project, Session, URL from tamr_client.categorization import project as categorization_project from tamr_client.exception import TamrClientException from tamr_client.mastering import project as mastering_project +from tamr_client.schema_mapping import project as schema_mapping_project class NotFound(TamrClientException): @@ -12,12 +15,20 @@ class NotFound(TamrClientException): pass +class AlreadyExists(TamrClientException): + """Raised when a project with these specifications already exists.""" + + pass + + def from_resource_id(session: Session, instance: Instance, id: str) -> Project: - """Get project by resource ID - Fetches project from Tamr server + """Get project by resource ID. + Fetches project from Tamr server. + Args: instance: Tamr instance containing this dataset id: Project ID + Raises: project.NotFound: If no project could be found at the specified URL. Corresponds to a 404 HTTP error. @@ -28,10 +39,12 @@ def from_resource_id(session: Session, instance: Instance, id: str) -> Project: def _from_url(session: Session, url: URL) -> Project: - """Get project by URL - Fetches project from Tamr server + """Get project by URL. + Fetches project from Tamr server. + Args: url: Project URL + Raises: NotFound: If no project could be found at the specified URL. Corresponds to a 404 HTTP error. @@ -55,5 +68,56 @@ def _from_json(url: URL, data: JsonDict) -> Project: return mastering_project._from_json(url, data) elif proj_type == "CATEGORIZATION": return categorization_project._from_json(url, data) + elif proj_type == "SCHEMA_MAPPING_RECOMMENDATIONS": + return schema_mapping_project._from_json(url, data) else: raise ValueError(f"Unrecognized project type '{proj_type}' in {repr(data)}") + + +def _create( + session: Session, + instance: Instance, + name: str, + project_type: str, + description: Optional[str] = None, + external_id: Optional[str] = None, + unified_dataset_name: Optional[str] = None, +) -> Project: + """Create a project in Tamr. + + Args: + instance: Tamr instance + name: Project name + project_type: Project type + description: Project description + external_id: External ID of the project + unified_dataset_name: Name of the unified dataset + + Returns: + Project created in Tamr + + Raises: + AlreadyExists: If a project with these specifications already exists. + requests.HTTPError: If any other HTTP error is encountered. + """ + if not unified_dataset_name: + unified_dataset_name = name + "_unified_dataset" + data = { + "name": name, + "type": project_type, + "unifiedDatasetName": unified_dataset_name, + "description": description, + "externalId": external_id, + } + + project_url = URL(instance=instance, path="projects") + r = session.post(url=str(project_url), json=data) + + if r.status_code == 409: + raise AlreadyExists(r.json()["message"]) + + data = response.successful(r).json() + project_path = data["relativeId"] + project_url = URL(instance=instance, path=str(project_path)) + + return _from_url(session=session, url=project_url) diff --git a/tamr_client/schema_mapping/__init__.py b/tamr_client/schema_mapping/__init__.py new file mode 100644 index 00000000..6ffac2ab --- /dev/null +++ b/tamr_client/schema_mapping/__init__.py @@ -0,0 +1,5 @@ +""" +Tamr - Schema Mapping +See https://docs.tamr.com/new/docs/overall-workflow-schema +""" +from tamr_client.schema_mapping import project diff --git a/tamr_client/schema_mapping/project.py b/tamr_client/schema_mapping/project.py new file mode 100644 index 00000000..6cc77fca --- /dev/null +++ b/tamr_client/schema_mapping/project.py @@ -0,0 +1,58 @@ +from typing import Optional + +from tamr_client import project +from tamr_client._types import ( + Instance, + JsonDict, + Project, + SchemaMappingProject, + Session, + URL, +) + + +def _from_json(url: URL, data: JsonDict) -> SchemaMappingProject: + """Make schema mapping project from JSON data (deserialize) + + Args: + url: Project URL + data: Project JSON data from Tamr server + """ + return SchemaMappingProject( + url, name=data["name"], description=data.get("description") + ) + + +def create( + session: Session, + instance: Instance, + name: str, + description: Optional[str] = None, + external_id: Optional[str] = None, + unified_dataset_name: Optional[str] = None, +) -> Project: + """Create a Schema Mapping project in Tamr. + + Args: + instance: Tamr instance + name: Project name + description: Project description + external_id: External ID of the project + unified_dataset_name: Unified dataset name. If None, will be set to project name + _'unified_dataset' + + Returns: + Project created in Tamr + + Raises: + AlreadyExists: If a project with these specifications already exists. + requests.HTTPError: If any other HTTP error is encountered. + """ + return project._create( + session=session, + instance=instance, + name=name, + project_type="SCHEMA_MAPPING_RECOMMENDATIONS", + description=description, + external_id=external_id, + unified_dataset_name=unified_dataset_name, + ) From c370bd64016ab2bbe3b9aee012141a908ff03250 Mon Sep 17 00:00:00 2001 From: skalish Date: Tue, 11 Aug 2020 20:42:04 -0400 Subject: [PATCH 532/632] Change test of record.stream to use fake.json. Add ndjson handling to fake.py --- tests/tamr_client/dataset/test_record.py | 8 +------- tests/tamr_client/fake.py | 6 +++++- .../fake_json/dataset/test_record/test_stream.json | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 8 deletions(-) create mode 100644 tests/tamr_client/fake_json/dataset/test_record/test_stream.json diff --git a/tests/tamr_client/dataset/test_record.py b/tests/tamr_client/dataset/test_record.py index 00315380..46841b12 100644 --- a/tests/tamr_client/dataset/test_record.py +++ b/tests/tamr_client/dataset/test_record.py @@ -1,5 +1,4 @@ from functools import partial -import json from typing import Dict import pytest @@ -155,16 +154,11 @@ def test_delete_infer_primary_key(): assert snoop["payload"] == utils.stringify(deletes) -@responses.activate +@fake.json def test_stream(): s = fake.session() dataset = fake.dataset() - url = tc.URL(path="datasets/1/records") - responses.add( - responses.GET, str(url), body="\n".join(json.dumps(x) for x in _records_json) - ) - records = tc.record.stream(s, dataset) assert list(records) == _records_json diff --git a/tests/tamr_client/fake.py b/tests/tamr_client/fake.py index bf3da6fa..b371b72f 100644 --- a/tests/tamr_client/fake.py +++ b/tests/tamr_client/fake.py @@ -6,7 +6,7 @@ from functools import wraps from inspect import getfile -from json import load +from json import dumps, load from pathlib import Path import responses @@ -27,6 +27,10 @@ def _to_kwargs(fake): path = req.get("path") url = "http://localhost/api/versioned/v1/" + path + ndjson = resp.pop("ndjson", None) + if ndjson is not None: + resp["body"] = "\n".join((dumps(line) for line in ndjson)) + return dict(method=req["method"], url=url, **resp) diff --git a/tests/tamr_client/fake_json/dataset/test_record/test_stream.json b/tests/tamr_client/fake_json/dataset/test_record/test_stream.json new file mode 100644 index 00000000..f46f3357 --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_record/test_stream.json @@ -0,0 +1,14 @@ +[ + { + "request": { + "method": "GET", + "path": "datasets/1/records" + }, + "response": { + "ndjson": [ + {"primary_key": 1}, + {"primary_key": 2} + ] + } + } +] \ No newline at end of file From 9b5b2fce1be6a3572f2f384ae2360cbca2abe001 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 13 Aug 2020 21:04:56 -0400 Subject: [PATCH 533/632] Change adding of responses in fake.json to allow checking of request body with add_callback. --- tests/tamr_client/dataset/test_record.py | 17 +--------- tests/tamr_client/fake.py | 27 +++++++++++++--- .../dataset/test_record/test_upsert.json | 32 +++++++++++++++++++ 3 files changed, 56 insertions(+), 20 deletions(-) create mode 100644 tests/tamr_client/fake_json/dataset/test_record/test_upsert.json diff --git a/tests/tamr_client/dataset/test_record.py b/tests/tamr_client/dataset/test_record.py index 46841b12..0398b15c 100644 --- a/tests/tamr_client/dataset/test_record.py +++ b/tests/tamr_client/dataset/test_record.py @@ -32,30 +32,15 @@ def test_update(): assert snoop["payload"] == utils.stringify(updates) -@responses.activate +@fake.json def test_upsert(): s = fake.session() dataset = fake.dataset() - url = tc.URL(path="datasets/1:updateRecords") - updates = [ - tc.record._create_command(record, primary_key_name="primary_key") - for record in _records_json - ] - snoop: Dict = {} - responses.add_callback( - responses.POST, - str(url), - partial( - utils.capture_payload, snoop=snoop, status=200, response_json=_response_json - ), - ) - response = tc.record.upsert( s, dataset, _records_json, primary_key_name="primary_key" ) assert response == _response_json - assert snoop["payload"] == utils.stringify(updates) @responses.activate diff --git a/tests/tamr_client/fake.py b/tests/tamr_client/fake.py index b371b72f..53ad7a10 100644 --- a/tests/tamr_client/fake.py +++ b/tests/tamr_client/fake.py @@ -4,7 +4,7 @@ For more, see "How to write tests" in the Contributor guide. """ -from functools import wraps +from functools import partial, wraps from inspect import getfile from json import dumps, load from pathlib import Path @@ -18,7 +18,14 @@ fake_json_dir = tests_tc_dir / "fake_json" -def _to_kwargs(fake): +def check_payload(request, correct_payload, status, response_json): + if correct_payload is not None: + if [x.decode("utf-8") for x in request.body] != correct_payload: + raise Exception("placeholder exception") + return status, {}, response_json + + +def add_response(rsps, fake): req = fake["request"] resp = fake["response"] @@ -31,7 +38,19 @@ def _to_kwargs(fake): if ndjson is not None: resp["body"] = "\n".join((dumps(line) for line in ndjson)) - return dict(method=req["method"], url=url, **resp) + payload = req.pop("payload", None) + callback = partial( + check_payload, + correct_payload=[dumps(x) for x in payload] if payload is not None else None, + status=resp.get("status", 200), # TODO: Every response needs a status + response_json=resp.get("body") or dumps(resp.get("json")), + ) + + rsps.add_callback( + method=req["method"], + url=url, + callback=callback, + ) def json(test_fn): @@ -51,7 +70,7 @@ def json(test_fn): def wrapper(*args, **kwargs): with responses.RequestsMock() as rsps: for fake in fakes: - rsps.add(**_to_kwargs(fake)) + add_response(rsps, fake) test_fn(*args, **kwargs) return wrapper diff --git a/tests/tamr_client/fake_json/dataset/test_record/test_upsert.json b/tests/tamr_client/fake_json/dataset/test_record/test_upsert.json new file mode 100644 index 00000000..cf45923e --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_record/test_upsert.json @@ -0,0 +1,32 @@ +[ + { + "request": { + "method": "POST", + "path": "datasets/1:updateRecords", + "payload": [ + { + "action": "CREATE", + "recordId": 1, + "record": { + "primary_key": 1 + } + }, + { + "action": "CREATE", + "recordId": 2, + "record": { + "primary_key": 2 + } + } + ] + }, + "response": { + "status": 204, + "json": { + "numCommandsProcessed": 2, + "allCommandsSucceeded": true, + "validationErrors": [] + } + } + } +] \ No newline at end of file From 0d9e1718669ec291f7cdb317e97781fee46903c3 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 14 Aug 2020 17:14:58 -0400 Subject: [PATCH 534/632] Ensure every fake response has a status. --- tests/tamr_client/fake.py | 2 +- .../fake_json/attribute/test_attribute/test_create.json | 1 + .../attribute/test_attribute/test_from_resource_id.json | 1 + .../fake_json/attribute/test_attribute/test_update.json | 1 + .../categorization/test_project/test_manual_labels.json | 3 +++ .../fake_json/dataset/test_dataset/test_attributes.json | 1 + .../fake_json/dataset/test_dataset/test_from_resource_id.json | 1 + .../tamr_client/fake_json/dataset/test_record/test_stream.json | 1 + tests/tamr_client/fake_json/test_instance/test_version.json | 1 + .../test_project/test_from_resource_id_categorization.json | 1 + .../test_project/test_from_resource_id_mastering.json | 1 + .../fake_json/test_transformations/test_get_all.json | 2 ++ .../fake_json/test_transformations/test_replace_all.json | 1 + 13 files changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/tamr_client/fake.py b/tests/tamr_client/fake.py index 53ad7a10..9d9a3355 100644 --- a/tests/tamr_client/fake.py +++ b/tests/tamr_client/fake.py @@ -42,7 +42,7 @@ def add_response(rsps, fake): callback = partial( check_payload, correct_payload=[dumps(x) for x in payload] if payload is not None else None, - status=resp.get("status", 200), # TODO: Every response needs a status + status=resp["status"], response_json=resp.get("body") or dumps(resp.get("json")), ) diff --git a/tests/tamr_client/fake_json/attribute/test_attribute/test_create.json b/tests/tamr_client/fake_json/attribute/test_attribute/test_create.json index 02224cb5..11088a74 100644 --- a/tests/tamr_client/fake_json/attribute/test_attribute/test_create.json +++ b/tests/tamr_client/fake_json/attribute/test_attribute/test_create.json @@ -5,6 +5,7 @@ "path": "datasets/1/attributes" }, "response": { + "status": 201, "json": { "name": "attr", "isNullable": false, diff --git a/tests/tamr_client/fake_json/attribute/test_attribute/test_from_resource_id.json b/tests/tamr_client/fake_json/attribute/test_attribute/test_from_resource_id.json index 4958bd64..2f843e34 100644 --- a/tests/tamr_client/fake_json/attribute/test_attribute/test_from_resource_id.json +++ b/tests/tamr_client/fake_json/attribute/test_attribute/test_from_resource_id.json @@ -5,6 +5,7 @@ "path": "datasets/1/attributes/attr" }, "response": { + "status": 200, "json": { "name": "attr", "isNullable": false, diff --git a/tests/tamr_client/fake_json/attribute/test_attribute/test_update.json b/tests/tamr_client/fake_json/attribute/test_attribute/test_update.json index 8bfe7ae6..0c1de2f2 100644 --- a/tests/tamr_client/fake_json/attribute/test_attribute/test_update.json +++ b/tests/tamr_client/fake_json/attribute/test_attribute/test_update.json @@ -5,6 +5,7 @@ "path": "datasets/1/attributes/RowNum" }, "response": { + "status": 200, "json": { "name": "RowNum", "description": "Synthetic row number updated", diff --git a/tests/tamr_client/fake_json/categorization/test_project/test_manual_labels.json b/tests/tamr_client/fake_json/categorization/test_project/test_manual_labels.json index 0101724e..5b4effb4 100644 --- a/tests/tamr_client/fake_json/categorization/test_project/test_manual_labels.json +++ b/tests/tamr_client/fake_json/categorization/test_project/test_manual_labels.json @@ -5,6 +5,7 @@ "path": "projects/2/unifiedDataset" }, "response": { + "status": 200, "json": { "id": "unify://unified-data/v1/datasets/161", "name": "Party_Categorization_Unified_Dataset", @@ -38,6 +39,7 @@ "path": "datasets?filter=name==Party_Categorization_Unified_Dataset_manual_categorizations" }, "response": { + "status": 200, "json": [ { "id": "unify://unified-data/v1/datasets/167", @@ -71,6 +73,7 @@ "path": "datasets/167" }, "response": { + "status": 200, "json": { "id": "unify://unified-data/v1/datasets/167", "name": "Party_Categorization_Unified_Dataset_manual_categorizations", diff --git a/tests/tamr_client/fake_json/dataset/test_dataset/test_attributes.json b/tests/tamr_client/fake_json/dataset/test_dataset/test_attributes.json index 01022b72..49ebc078 100644 --- a/tests/tamr_client/fake_json/dataset/test_dataset/test_attributes.json +++ b/tests/tamr_client/fake_json/dataset/test_dataset/test_attributes.json @@ -5,6 +5,7 @@ "path": "datasets/1/attributes" }, "response": { + "status": 200, "json": [ { "name": "RowNum", diff --git a/tests/tamr_client/fake_json/dataset/test_dataset/test_from_resource_id.json b/tests/tamr_client/fake_json/dataset/test_dataset/test_from_resource_id.json index 2d7a3b2f..dad693f2 100644 --- a/tests/tamr_client/fake_json/dataset/test_dataset/test_from_resource_id.json +++ b/tests/tamr_client/fake_json/dataset/test_dataset/test_from_resource_id.json @@ -5,6 +5,7 @@ "path": "datasets/1" }, "response": { + "status": 200, "json": { "id": "unify://unified-data/v1/datasets/1", "externalId": "number 1", diff --git a/tests/tamr_client/fake_json/dataset/test_record/test_stream.json b/tests/tamr_client/fake_json/dataset/test_record/test_stream.json index f46f3357..b03b718e 100644 --- a/tests/tamr_client/fake_json/dataset/test_record/test_stream.json +++ b/tests/tamr_client/fake_json/dataset/test_record/test_stream.json @@ -5,6 +5,7 @@ "path": "datasets/1/records" }, "response": { + "status": 200, "ndjson": [ {"primary_key": 1}, {"primary_key": 2} diff --git a/tests/tamr_client/fake_json/test_instance/test_version.json b/tests/tamr_client/fake_json/test_instance/test_version.json index 6f6da1fd..2bedc1fb 100644 --- a/tests/tamr_client/fake_json/test_instance/test_version.json +++ b/tests/tamr_client/fake_json/test_instance/test_version.json @@ -5,6 +5,7 @@ "url": "http://localhost/api/versioned/service/version" }, "response": { + "status": 200, "json": { "version": "2020.012.0", "gitDescribe": "Element/release/1.0.3-26513-gab2085fb5d", diff --git a/tests/tamr_client/fake_json/test_project/test_from_resource_id_categorization.json b/tests/tamr_client/fake_json/test_project/test_from_resource_id_categorization.json index 1c87b8f3..51c11afa 100644 --- a/tests/tamr_client/fake_json/test_project/test_from_resource_id_categorization.json +++ b/tests/tamr_client/fake_json/test_project/test_from_resource_id_categorization.json @@ -5,6 +5,7 @@ "path": "projects/2" }, "response": { + "status": 200, "json": { "id": "unify://unified-data/v1/projects/2", "name": "Party Categorization", diff --git a/tests/tamr_client/fake_json/test_project/test_from_resource_id_mastering.json b/tests/tamr_client/fake_json/test_project/test_from_resource_id_mastering.json index 96c78c8d..21e6dbfc 100644 --- a/tests/tamr_client/fake_json/test_project/test_from_resource_id_mastering.json +++ b/tests/tamr_client/fake_json/test_project/test_from_resource_id_mastering.json @@ -5,6 +5,7 @@ "path": "projects/1" }, "response": { + "status": 200, "json": { "id": "unify://unified-data/v1/projects/1", "name": "proj", diff --git a/tests/tamr_client/fake_json/test_transformations/test_get_all.json b/tests/tamr_client/fake_json/test_transformations/test_get_all.json index a108b34c..3b5f6a0a 100644 --- a/tests/tamr_client/fake_json/test_transformations/test_get_all.json +++ b/tests/tamr_client/fake_json/test_transformations/test_get_all.json @@ -5,6 +5,7 @@ "path": "projects/1/transformations" }, "response": { + "status": 200, "json": { "parameterized": [ { @@ -34,6 +35,7 @@ "path": "datasets/1" }, "response": { + "status": 200, "json": { "id": "unify://unified-data/v1/datasets/1", "externalId": "number 1", diff --git a/tests/tamr_client/fake_json/test_transformations/test_replace_all.json b/tests/tamr_client/fake_json/test_transformations/test_replace_all.json index cf69b1c5..2d2dc3d5 100644 --- a/tests/tamr_client/fake_json/test_transformations/test_replace_all.json +++ b/tests/tamr_client/fake_json/test_transformations/test_replace_all.json @@ -5,6 +5,7 @@ "path": "projects/1/transformations" }, "response": { + "status": 200, "json": { "parameterized": [ { From 6a9a47bd99c946b8a0fd641552cf499eb66d660f Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 14 Aug 2020 17:33:00 -0400 Subject: [PATCH 535/632] Clean up fake.check_request_body and fake.add_response. --- tests/tamr_client/fake.py | 31 ++++++++++++------- .../dataset/test_record/test_upsert.json | 2 +- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/tests/tamr_client/fake.py b/tests/tamr_client/fake.py index 9d9a3355..7d0d24d2 100644 --- a/tests/tamr_client/fake.py +++ b/tests/tamr_client/fake.py @@ -18,9 +18,9 @@ fake_json_dir = tests_tc_dir / "fake_json" -def check_payload(request, correct_payload, status, response_json): - if correct_payload is not None: - if [x.decode("utf-8") for x in request.body] != correct_payload: +def check_request_body(request, request_body, status, response_json): + if request_body is not None: + if [x.decode("utf-8") for x in request.body] != request_body: raise Exception("placeholder exception") return status, {}, response_json @@ -34,22 +34,29 @@ def add_response(rsps, fake): path = req.get("path") url = "http://localhost/api/versioned/v1/" + path - ndjson = resp.pop("ndjson", None) + # Get response body from either ndjson or json + ndjson = resp.get("ndjson") if ndjson is not None: resp["body"] = "\n".join((dumps(line) for line in ndjson)) + else: + resp["body"] = dumps(resp.get("json")) - payload = req.pop("payload", None) - callback = partial( - check_payload, - correct_payload=[dumps(x) for x in payload] if payload is not None else None, - status=resp["status"], - response_json=resp.get("body") or dumps(resp.get("json")), - ) + # Get expected request body from ndjson + payload = req.get("ndjson") + if payload is not None: + req["body"] = [dumps(x) for x in payload] + else: + req["body"] = None rsps.add_callback( method=req["method"], url=url, - callback=callback, + callback=partial( + check_request_body, + request_body=req["body"], + status=resp["status"], + response_json=resp["body"], + ), ) diff --git a/tests/tamr_client/fake_json/dataset/test_record/test_upsert.json b/tests/tamr_client/fake_json/dataset/test_record/test_upsert.json index cf45923e..de3de037 100644 --- a/tests/tamr_client/fake_json/dataset/test_record/test_upsert.json +++ b/tests/tamr_client/fake_json/dataset/test_record/test_upsert.json @@ -3,7 +3,7 @@ "request": { "method": "POST", "path": "datasets/1:updateRecords", - "payload": [ + "ndjson": [ { "action": "CREATE", "recordId": 1, From 5f0a5d2805a10cbb97ddd6a37f5c0c47b704c878 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 14 Aug 2020 18:02:22 -0400 Subject: [PATCH 536/632] Add test of request with json payload. Tests of ndjson payload already exists. --- tests/tamr_client/fake.py | 43 ++++++++++------ .../attribute/test_attribute/test_create.json | 51 ++++++++++++++++++- 2 files changed, 77 insertions(+), 17 deletions(-) diff --git a/tests/tamr_client/fake.py b/tests/tamr_client/fake.py index 7d0d24d2..b7090c51 100644 --- a/tests/tamr_client/fake.py +++ b/tests/tamr_client/fake.py @@ -6,7 +6,7 @@ from functools import partial, wraps from inspect import getfile -from json import dumps, load +from json import dumps, load, loads from pathlib import Path import responses @@ -18,10 +18,23 @@ fake_json_dir = tests_tc_dir / "fake_json" +class WrongRequestBody(Exception): + """Raised when the body of a request does not match the value expected during + testing + """ + + pass + + def check_request_body(request, request_body, status, response_json): - if request_body is not None: - if [x.decode("utf-8") for x in request.body] != request_body: - raise Exception("placeholder exception") + if isinstance(request_body, list): + caught_body = [loads(x.decode("utf-8")) for x in request.body] + if caught_body != request_body: + raise WrongRequestBody(caught_body) + elif request_body is not None: + caught_body = loads(request.body.decode("utf-8")) + if caught_body != request_body: + raise WrongRequestBody(caught_body) return status, {}, response_json @@ -35,27 +48,25 @@ def add_response(rsps, fake): url = "http://localhost/api/versioned/v1/" + path # Get response body from either ndjson or json - ndjson = resp.get("ndjson") - if ndjson is not None: - resp["body"] = "\n".join((dumps(line) for line in ndjson)) - else: - resp["body"] = dumps(resp.get("json")) + if resp.get("ndjson") is not None: + resp["body"] = "\n".join((dumps(line) for line in resp["ndjson"])) + elif resp.get("json") is not None: + resp["body"] = dumps(resp["json"]) # Get expected request body from ndjson - payload = req.get("ndjson") - if payload is not None: - req["body"] = [dumps(x) for x in payload] - else: - req["body"] = None + if req.get("ndjson") is not None: + req["body"] = [x for x in req["ndjson"]] + elif req.get("json") is not None: + req["body"] = req["json"] rsps.add_callback( method=req["method"], url=url, callback=partial( check_request_body, - request_body=req["body"], + request_body=req.get("body"), status=resp["status"], - response_json=resp["body"], + response_json=resp.get("body"), ), ) diff --git a/tests/tamr_client/fake_json/attribute/test_attribute/test_create.json b/tests/tamr_client/fake_json/attribute/test_attribute/test_create.json index 11088a74..828e7196 100644 --- a/tests/tamr_client/fake_json/attribute/test_attribute/test_create.json +++ b/tests/tamr_client/fake_json/attribute/test_attribute/test_create.json @@ -2,7 +2,56 @@ { "request": { "method": "POST", - "path": "datasets/1/attributes" + "path": "datasets/1/attributes", + "json": { + "name": "attr", + "isNullable": false, + "type": { + "baseType": "RECORD", + "attributes": [ + { + "name": "0", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + }, + { + "name": "1", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + }, + { + "name": "2", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + }, + { + "name": "3", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + } + ] + } + } }, "response": { "status": 201, From 28923db76b24e69a1454ceec832c1d49571e5f08 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 14 Aug 2020 18:17:18 -0400 Subject: [PATCH 537/632] Update remaining tc.record tests. --- tests/tamr_client/dataset/test_record.py | 71 ++----------------- .../dataset/test_record/test_delete.json | 26 +++++++ .../test_delete_infer_primary_key.json | 26 +++++++ .../dataset/test_record/test_update.json | 32 +++++++++ .../test_upsert_infer_primary_key.json | 32 +++++++++ 5 files changed, 121 insertions(+), 66 deletions(-) create mode 100644 tests/tamr_client/fake_json/dataset/test_record/test_delete.json create mode 100644 tests/tamr_client/fake_json/dataset/test_record/test_delete_infer_primary_key.json create mode 100644 tests/tamr_client/fake_json/dataset/test_record/test_update.json create mode 100644 tests/tamr_client/fake_json/dataset/test_record/test_upsert_infer_primary_key.json diff --git a/tests/tamr_client/dataset/test_record.py b/tests/tamr_client/dataset/test_record.py index 0398b15c..4bbc5a14 100644 --- a/tests/tamr_client/dataset/test_record.py +++ b/tests/tamr_client/dataset/test_record.py @@ -1,35 +1,21 @@ -from functools import partial -from typing import Dict - import pytest -import responses import tamr_client as tc -from tests.tamr_client import fake, utils +from tests.tamr_client import fake -@responses.activate +@fake.json def test_update(): s = fake.session() dataset = fake.dataset() - url = tc.URL(path="datasets/1:updateRecords") updates = [ tc.record._create_command(record, primary_key_name="primary_key") for record in _records_json ] - snoop: Dict = {} - responses.add_callback( - responses.POST, - str(url), - partial( - utils.capture_payload, snoop=snoop, status=200, response_json=_response_json - ), - ) response = tc.record._update(s, dataset, updates) assert response == _response_json - assert snoop["payload"] == utils.stringify(updates) @fake.json @@ -43,7 +29,6 @@ def test_upsert(): assert response == _response_json -@responses.activate def test_upsert_primary_key_not_found(): s = fake.session() dataset = fake.dataset() @@ -54,57 +39,26 @@ def test_upsert_primary_key_not_found(): ) -@responses.activate +@fake.json def test_upsert_infer_primary_key(): s = fake.session() dataset = fake.dataset() - url = tc.URL(path="datasets/1:updateRecords") - updates = [ - tc.record._create_command(record, primary_key_name="primary_key") - for record in _records_json - ] - snoop: Dict = {} - responses.add_callback( - responses.POST, - str(url), - partial( - utils.capture_payload, snoop=snoop, status=200, response_json=_response_json - ), - ) - response = tc.record.upsert(s, dataset, _records_json) assert response == _response_json - assert snoop["payload"] == utils.stringify(updates) -@responses.activate +@fake.json def test_delete(): s = fake.session() dataset = fake.dataset() - url = tc.URL(path="datasets/1:updateRecords") - deletes = [ - tc.record._delete_command(record, primary_key_name="primary_key") - for record in _records_json - ] - snoop: Dict = {} - responses.add_callback( - responses.POST, - str(url), - partial( - utils.capture_payload, snoop=snoop, status=200, response_json=_response_json - ), - ) - response = tc.record.delete( s, dataset, _records_json, primary_key_name="primary_key" ) assert response == _response_json - assert snoop["payload"] == utils.stringify(deletes) -@responses.activate def test_delete_primary_key_not_found(): s = fake.session() dataset = fake.dataset() @@ -115,28 +69,13 @@ def test_delete_primary_key_not_found(): ) -@responses.activate +@fake.json def test_delete_infer_primary_key(): s = fake.session() dataset = fake.dataset() - url = tc.URL(path="datasets/1:updateRecords") - deletes = [ - tc.record._delete_command(record, primary_key_name="primary_key") - for record in _records_json - ] - snoop: Dict = {} - responses.add_callback( - responses.POST, - str(url), - partial( - utils.capture_payload, snoop=snoop, status=200, response_json=_response_json - ), - ) - response = tc.record.delete(s, dataset, _records_json) assert response == _response_json - assert snoop["payload"] == utils.stringify(deletes) @fake.json diff --git a/tests/tamr_client/fake_json/dataset/test_record/test_delete.json b/tests/tamr_client/fake_json/dataset/test_record/test_delete.json new file mode 100644 index 00000000..6c180542 --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_record/test_delete.json @@ -0,0 +1,26 @@ +[ + { + "request": { + "method": "POST", + "path": "datasets/1:updateRecords", + "ndjson": [ + { + "action": "DELETE", + "recordId": 1 + }, + { + "action": "DELETE", + "recordId": 2 + } + ] + }, + "response": { + "status": 204, + "json": { + "numCommandsProcessed": 2, + "allCommandsSucceeded": true, + "validationErrors": [] + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/dataset/test_record/test_delete_infer_primary_key.json b/tests/tamr_client/fake_json/dataset/test_record/test_delete_infer_primary_key.json new file mode 100644 index 00000000..6c180542 --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_record/test_delete_infer_primary_key.json @@ -0,0 +1,26 @@ +[ + { + "request": { + "method": "POST", + "path": "datasets/1:updateRecords", + "ndjson": [ + { + "action": "DELETE", + "recordId": 1 + }, + { + "action": "DELETE", + "recordId": 2 + } + ] + }, + "response": { + "status": 204, + "json": { + "numCommandsProcessed": 2, + "allCommandsSucceeded": true, + "validationErrors": [] + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/dataset/test_record/test_update.json b/tests/tamr_client/fake_json/dataset/test_record/test_update.json new file mode 100644 index 00000000..de3de037 --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_record/test_update.json @@ -0,0 +1,32 @@ +[ + { + "request": { + "method": "POST", + "path": "datasets/1:updateRecords", + "ndjson": [ + { + "action": "CREATE", + "recordId": 1, + "record": { + "primary_key": 1 + } + }, + { + "action": "CREATE", + "recordId": 2, + "record": { + "primary_key": 2 + } + } + ] + }, + "response": { + "status": 204, + "json": { + "numCommandsProcessed": 2, + "allCommandsSucceeded": true, + "validationErrors": [] + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/dataset/test_record/test_upsert_infer_primary_key.json b/tests/tamr_client/fake_json/dataset/test_record/test_upsert_infer_primary_key.json new file mode 100644 index 00000000..de3de037 --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_record/test_upsert_infer_primary_key.json @@ -0,0 +1,32 @@ +[ + { + "request": { + "method": "POST", + "path": "datasets/1:updateRecords", + "ndjson": [ + { + "action": "CREATE", + "recordId": 1, + "record": { + "primary_key": 1 + } + }, + { + "action": "CREATE", + "recordId": 2, + "record": { + "primary_key": 2 + } + } + ] + }, + "response": { + "status": 204, + "json": { + "numCommandsProcessed": 2, + "allCommandsSucceeded": true, + "validationErrors": [] + } + } + } +] \ No newline at end of file From 46d604c29b8a333bc44b9ca351c59f8a209d8d3c Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 19 Aug 2020 10:50:09 -0400 Subject: [PATCH 538/632] Change body variable names in check_request_body to conform to standard testing language. --- tests/tamr_client/fake.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/tamr_client/fake.py b/tests/tamr_client/fake.py index b7090c51..e02c74ab 100644 --- a/tests/tamr_client/fake.py +++ b/tests/tamr_client/fake.py @@ -26,15 +26,15 @@ class WrongRequestBody(Exception): pass -def check_request_body(request, request_body, status, response_json): - if isinstance(request_body, list): - caught_body = [loads(x.decode("utf-8")) for x in request.body] - if caught_body != request_body: - raise WrongRequestBody(caught_body) - elif request_body is not None: - caught_body = loads(request.body.decode("utf-8")) - if caught_body != request_body: - raise WrongRequestBody(caught_body) +def check_request_body(request, expected_body, status, response_json): + if isinstance(expected_body, list): + actual_body = [loads(x.decode("utf-8")) for x in request.body] + if actual_body != expected_body: + raise WrongRequestBody(actual_body) + elif expected_body is not None: + actual_body = loads(request.body.decode("utf-8")) + if actual_body != expected_body: + raise WrongRequestBody(actual_body) return status, {}, response_json @@ -64,7 +64,7 @@ def add_response(rsps, fake): url=url, callback=partial( check_request_body, - request_body=req.get("body"), + expected_body=req.get("body"), status=resp["status"], response_json=resp.get("body"), ), From 7fa2e355514f36ba21a7ebfbe72fb67a09e0f76d Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 19 Aug 2020 10:50:45 -0400 Subject: [PATCH 539/632] Remove unnecessary list comprehension. --- tests/tamr_client/fake.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tamr_client/fake.py b/tests/tamr_client/fake.py index e02c74ab..d8771917 100644 --- a/tests/tamr_client/fake.py +++ b/tests/tamr_client/fake.py @@ -55,7 +55,7 @@ def add_response(rsps, fake): # Get expected request body from ndjson if req.get("ndjson") is not None: - req["body"] = [x for x in req["ndjson"]] + req["body"] = req["ndjson"] elif req.get("json") is not None: req["body"] = req["json"] From fb3090ae65cfb511e6a85142b7973cef8f7d99d6 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 19 Aug 2020 10:55:44 -0400 Subject: [PATCH 540/632] Split callback and check_request_body. Rename with _ prefix for both. --- tests/tamr_client/fake.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/tamr_client/fake.py b/tests/tamr_client/fake.py index d8771917..1f19d088 100644 --- a/tests/tamr_client/fake.py +++ b/tests/tamr_client/fake.py @@ -26,7 +26,7 @@ class WrongRequestBody(Exception): pass -def check_request_body(request, expected_body, status, response_json): +def _check_request_body(request, expected_body): if isinstance(expected_body, list): actual_body = [loads(x.decode("utf-8")) for x in request.body] if actual_body != expected_body: @@ -35,6 +35,10 @@ def check_request_body(request, expected_body, status, response_json): actual_body = loads(request.body.decode("utf-8")) if actual_body != expected_body: raise WrongRequestBody(actual_body) + + +def _callback(request, expected_body, status, response_json): + _check_request_body(request, expected_body) return status, {}, response_json @@ -63,7 +67,7 @@ def add_response(rsps, fake): method=req["method"], url=url, callback=partial( - check_request_body, + _callback, expected_body=req.get("body"), status=resp["status"], response_json=resp.get("body"), From 1f7ee65530b923b4d07e33583fbbdcbe20bfde29 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 19 Aug 2020 19:38:33 -0400 Subject: [PATCH 541/632] Add descriptive docstrings. --- tests/tamr_client/fake.py | 40 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/tests/tamr_client/fake.py b/tests/tamr_client/fake.py index 1f19d088..9da3ab66 100644 --- a/tests/tamr_client/fake.py +++ b/tests/tamr_client/fake.py @@ -8,10 +8,12 @@ from inspect import getfile from json import dumps, load, loads from pathlib import Path +from typing import Dict, Tuple import responses import tamr_client as tc +from tamr_client._types import JsonDict tests_tc_dir = (Path(__file__) / "..").resolve() @@ -26,7 +28,18 @@ class WrongRequestBody(Exception): pass -def _check_request_body(request, expected_body): +def _check_request_body(request, expected_body: JsonDict): + """Checks that the body of a caught request matches the expected content + + The body is decoded and loaded as a JSON object so the comparison is not sensitive to the + order of dictionary contents. The comparison is sensitive to the order of a newline-delimited + JSON request body. + + Args: + request: The caught request + expected_body: The expected request body as a dictionary (for JSON contents) or a list of + dictionaries (for newline-delimited JSON contents) + """ if isinstance(expected_body, list): actual_body = [loads(x.decode("utf-8")) for x in request.body] if actual_body != expected_body: @@ -37,12 +50,33 @@ def _check_request_body(request, expected_body): raise WrongRequestBody(actual_body) -def _callback(request, expected_body, status, response_json): +def _callback( + request, expected_body: JsonDict, status: int, response_json: str +) -> Tuple[int, Dict, str]: + """Adds a callback to intercept an API request, check the validity of the request, and emit a + response + + Args: + expected_body: The expected request body as a dictionary (for JSON contents) or a list of + dictionaries (for newline-delimited JSON contents) + status: The status of the response to be emitted + response_json: The JSON body of the response to be emitted + + Returns: + Response status, headers, and JSON body. This conforms to the callback interface of + `~responses.RequestsMock.add_callback` + """ _check_request_body(request, expected_body) return status, {}, response_json -def add_response(rsps, fake): +def add_response(rsps, fake: JsonDict): + """Adds a mock response to intercept API requests and respond with fake JSON data + + Args: + fake: The JSON dictionary containing the fake data defining what requests to intercept and + what responses to emit + """ req = fake["request"] resp = fake["response"] From 997e952344a5be0d7f1042ba37f9dd47be901e69 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 19 Aug 2020 19:06:41 -0400 Subject: [PATCH 542/632] Add functions for basic mastering workflow operations. --- tamr_client/mastering/__init__.py | 10 ++++ tamr_client/mastering/_mastering.py | 83 +++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 tamr_client/mastering/_mastering.py diff --git a/tamr_client/mastering/__init__.py b/tamr_client/mastering/__init__.py index db75a977..455f552b 100644 --- a/tamr_client/mastering/__init__.py +++ b/tamr_client/mastering/__init__.py @@ -3,3 +3,13 @@ See https://docs.tamr.com/docs/overall-workflow-mastering """ from tamr_client.mastering import project +from tamr_client.mastering._mastering import ( + apply_feedback, + estimate_pairs, + generate_pairs, + publish_clusters, + update_cluster_results, + update_high_impact_pairs, + update_pair_results, + update_unified_dataset, +) diff --git a/tamr_client/mastering/_mastering.py b/tamr_client/mastering/_mastering.py new file mode 100644 index 00000000..5ea8f399 --- /dev/null +++ b/tamr_client/mastering/_mastering.py @@ -0,0 +1,83 @@ +from tamr_client import operation +from tamr_client._types import MasteringProject, Operation, Session +from tamr_client.dataset import unified + + +def update_unified_dataset(session: Session, project: MasteringProject) -> Operation: + """Applies changes to the unified dataset and waits for the operation to complete + + Args: + project: Tamr Mastering project + """ + unified_dataset = unified.from_project(session, project.url.instance, project) + return unified.apply_changes(session, unified_dataset) + + +def estimate_pairs(session: Session, project: MasteringProject) -> Operation: + """Updates the estimated pair counts + + Args: + project: Tamr Mastering project + """ + r = session.post(str(project.url) + "estimatedPairCounts:refresh") + return operation._from_response(project.url.instance, r) + + +def generate_pairs(session: Session, project: MasteringProject) -> Operation: + """Generates pairs according to the binning model + + Args: + project: Tamr Mastering project + """ + r = session.post(str(project.url) + "recordPairs:refresh") + return operation._from_response(project.url.instance, r) + + +def apply_feedback(session: Session, project: MasteringProject) -> Operation: + """Trains the pair-matching model according to verified labels + + Args: + project: Tamr Mastering project + """ + r = session.post(str(project.url) + "recordPairsWithPredictions/model:refresh") + return operation._from_response(project.url.instance, r) + + +def update_pair_results(session: Session, project: MasteringProject) -> Operation: + """Update record pair predictions according to the latest pair-matching model + + Args: + project: Tamr Mastering project + """ + r = session.post(str(project.url) + "recordPairsWithPredictions:refresh") + return operation._from_response(project.url.instance, r) + + +def update_high_impact_pairs(session: Session, project: MasteringProject) -> Operation: + """Produces new high-impact pairs according to the latest pair-matching model + + Args: + project: Tamr Mastering project + """ + r = session.post(str(project.url) + "highImpactPairs:refresh") + return operation._from_response(project.url.instance, r) + + +def update_cluster_results(session: Session, project: MasteringProject) -> Operation: + """Generates clusters based on the latest pair-matching model + + Args: + project: Tamr Mastering project + """ + r = session.post(str(project.url) + "recordClusters:refresh") + return operation._from_response(project.url.instance, r) + + +def publish_clusters(session: Session, project: MasteringProject) -> Operation: + """Publishes current record clusters + + Args: + project: Tamr Mastering project + """ + r = session.post(str(project.url) + "publishedClustersWithData:refresh") + return operation._from_response(project.url.instance, r) From 2c684ec2c0ab9ea8112491ac2fd4532084c585c0 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 20 Aug 2020 11:31:07 -0400 Subject: [PATCH 543/632] Add module docstring. --- tamr_client/mastering/_mastering.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tamr_client/mastering/_mastering.py b/tamr_client/mastering/_mastering.py index 5ea8f399..9ceabd94 100644 --- a/tamr_client/mastering/_mastering.py +++ b/tamr_client/mastering/_mastering.py @@ -1,3 +1,9 @@ +""" +Tamr - Mastering +See https://docs.tamr.com/docs/overall-workflow-mastering + +The terminology used here is consistent with Tamr UI terminology +""" from tamr_client import operation from tamr_client._types import MasteringProject, Operation, Session from tamr_client.dataset import unified @@ -44,7 +50,7 @@ def apply_feedback(session: Session, project: MasteringProject) -> Operation: def update_pair_results(session: Session, project: MasteringProject) -> Operation: - """Update record pair predictions according to the latest pair-matching model + """Updates record pair predictions according to the latest pair-matching model Args: project: Tamr Mastering project From 86600400994f3d706ebab7c2d4d980c37d0baec5 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 20 Aug 2020 11:43:14 -0400 Subject: [PATCH 544/632] Update docs. --- docs/beta/mastering.md | 1 + docs/beta/mastering/mastering.rst | 11 +++++++++++ 2 files changed, 12 insertions(+) create mode 100644 docs/beta/mastering/mastering.rst diff --git a/docs/beta/mastering.md b/docs/beta/mastering.md index f10e8d68..c833f55d 100644 --- a/docs/beta/mastering.md +++ b/docs/beta/mastering.md @@ -1,3 +1,4 @@ # Mastering + * [Mastering](/beta/mastering/mastering) * [Project](/beta/mastering/project) diff --git a/docs/beta/mastering/mastering.rst b/docs/beta/mastering/mastering.rst new file mode 100644 index 00000000..274bb07d --- /dev/null +++ b/docs/beta/mastering/mastering.rst @@ -0,0 +1,11 @@ +Mastering +========= + +.. autofunction:: tamr_client.mastering.update_unified_dataset +.. autofunction:: tamr_client.mastering.estimate_pairs +.. autofunction:: tamr_client.mastering.generate_pairs +.. autofunction:: tamr_client.mastering.apply_feedback +.. autofunction:: tamr_client.mastering.update_pair_results +.. autofunction:: tamr_client.mastering.update_high_impact_pairs +.. autofunction:: tamr_client.mastering.update_cluster_results +.. autofunction:: tamr_client.mastering.publish_clusters From 17561767454ff417332ea7ecab0ab919df4a4f53 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 20 Aug 2020 12:33:19 -0400 Subject: [PATCH 545/632] Update changelog. --- CHANGELOG.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 386caacf..b8ba1369 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,17 @@ ## 0.13.0-dev **BETA** Important: Do not use BETA features for production workflows. - - Added function to get operation from resource ID + - [#383](https://github.com/Datatamer/tamr-client/issues/383) Added function to get operation from resource ID - [#421](https://github.com/Datatamer/tamr-client/pull/421) Added functions for getting and replacing the transformations of a projects via `tc.transformations.get_all()` and `tc.transformations.replace_all()` - - Added new dataclasses `Transformations` and `InputTransformations` to support these functions - - Moved function `tc.attribute.from_dataset_all` to `tc.dataset.attributes` - - [#434] Added `tc.instance.version` function to get Tamr Version + - Added new dataclasses `Transformations` and `InputTransformations` to support these functions + - [#425](https://github.com/Datatamer/tamr-client/pull/425) Now able to get, update and delete manual labels for Categorization projects + - [#428](https://github.com/Datatamer/tamr-client/pull/428) Moved function `tc.attribute.from_dataset_all` to `tc.dataset.attributes` + - [#434](https://github.com/Datatamer/tamr-client/pull/434) Added `tc.instance.version` function to get Tamr Version + - [#435](https://github.com/Datatamer/tamr-client/pull/435) Now able to create projects of the following type in Tamr: Categorization, Mastering, Schema Mapping + - [#440](https://github.com/Datatamer/tamr-client/pull/440) Added functions for initiating basic mastering workflow operations in `tc.mastering` **NEW FEATURES** - [#383](https://github.com/Datatamer/tamr-client/issues/383) Now able to create an Operation from Job resource id - - [#425](https://github.com/Datatamer/tamr-client/pull/425) Now able to get, update and delete manual labels for Categorization projects - - [#435](https://github.com/Datatamer/tamr-client/pull/435) Now able to create projects of the following type in Tamr: Categorization, Mastering, Schema Mapping ## 0.12.0 **BETA** From 16b68eeb64094dafb0a3b874888cc20c15ae450a Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 20 Aug 2020 18:26:22 -0400 Subject: [PATCH 546/632] Fix request urls and wrap async functions in user-exposed waiting functions. --- tamr_client/mastering/_mastering.py | 139 +++++++++++++++++++++++----- 1 file changed, 116 insertions(+), 23 deletions(-) diff --git a/tamr_client/mastering/_mastering.py b/tamr_client/mastering/_mastering.py index 9ceabd94..f8c94840 100644 --- a/tamr_client/mastering/_mastering.py +++ b/tamr_client/mastering/_mastering.py @@ -10,80 +10,173 @@ def update_unified_dataset(session: Session, project: MasteringProject) -> Operation: - """Applies changes to the unified dataset and waits for the operation to complete + """Apply changes to the unified dataset and wait for the operation to complete Args: project: Tamr Mastering project """ - unified_dataset = unified.from_project(session, project.url.instance, project) - return unified.apply_changes(session, unified_dataset) + op = _update_unified_dataset_async(session, project) + return operation.wait(session, op) def estimate_pairs(session: Session, project: MasteringProject) -> Operation: - """Updates the estimated pair counts + """Update the estimated pair counts and wait for the operation to complete Args: project: Tamr Mastering project """ - r = session.post(str(project.url) + "estimatedPairCounts:refresh") - return operation._from_response(project.url.instance, r) + op = _estimate_pairs_async(session, project) + return operation.wait(session, op) def generate_pairs(session: Session, project: MasteringProject) -> Operation: - """Generates pairs according to the binning model + """Generate pairs according to the binning model and wait for the operation + to complete Args: project: Tamr Mastering project """ - r = session.post(str(project.url) + "recordPairs:refresh") - return operation._from_response(project.url.instance, r) + op = _generate_pairs_async(session, project) + return operation.wait(session, op) def apply_feedback(session: Session, project: MasteringProject) -> Operation: - """Trains the pair-matching model according to verified labels + """Train the pair-matching model according to verified labels and wait for the + operation to complete Args: project: Tamr Mastering project """ - r = session.post(str(project.url) + "recordPairsWithPredictions/model:refresh") - return operation._from_response(project.url.instance, r) + op = _apply_feedback_async(session, project) + return operation.wait(session, op) def update_pair_results(session: Session, project: MasteringProject) -> Operation: - """Updates record pair predictions according to the latest pair-matching model + """Update record pair predictions according to the latest pair-matching model and + wait for the operation to complete Args: project: Tamr Mastering project """ - r = session.post(str(project.url) + "recordPairsWithPredictions:refresh") - return operation._from_response(project.url.instance, r) + op = _update_pair_results_async(session, project) + return operation.wait(session, op) def update_high_impact_pairs(session: Session, project: MasteringProject) -> Operation: - """Produces new high-impact pairs according to the latest pair-matching model + """Produce new high-impact pairs according to the latest pair-matching model and + wait for the operation to complete Args: project: Tamr Mastering project """ - r = session.post(str(project.url) + "highImpactPairs:refresh") - return operation._from_response(project.url.instance, r) + op = _update_high_impact_pairs_async(session, project) + return operation.wait(session, op) def update_cluster_results(session: Session, project: MasteringProject) -> Operation: - """Generates clusters based on the latest pair-matching model + """Generate clusters based on the latest pair-matching model and wait for the + operation to complete Args: project: Tamr Mastering project """ - r = session.post(str(project.url) + "recordClusters:refresh") - return operation._from_response(project.url.instance, r) + op = _update_cluster_results_async(session, project) + return operation.wait(session, op) def publish_clusters(session: Session, project: MasteringProject) -> Operation: - """Publishes current record clusters + """Publish current record clusters and wait for the operation to complete + + Args: + project: Tamr Mastering project + """ + op = _publish_clusters_async(session, project) + return operation.wait(session, op) + + +def _update_unified_dataset_async( + session: Session, project: MasteringProject +) -> Operation: + """Apply changes to the unified dataset + + Args: + project: Tamr Mastering project + """ + unified_dataset = unified.from_project(session, project.url.instance, project) + return unified._apply_changes_async(session, unified_dataset) + + +def _estimate_pairs_async(session: Session, project: MasteringProject) -> Operation: + """Update the estimated pair counts + + Args: + project: Tamr Mastering project + """ + r = session.post(str(project.url) + "/estimatedPairCounts:refresh") + return operation._from_response(project.url.instance, r) + + +def _generate_pairs_async(session: Session, project: MasteringProject) -> Operation: + """Generate pairs according to the binning model + + Args: + project: Tamr Mastering project + """ + r = session.post(str(project.url) + "/recordPairs:refresh") + return operation._from_response(project.url.instance, r) + + +def _apply_feedback_async(session: Session, project: MasteringProject) -> Operation: + """Train the pair-matching model according to verified labels + + Args: + project: Tamr Mastering project + """ + r = session.post(str(project.url) + "/recordPairsWithPredictions/model:refresh") + return operation._from_response(project.url.instance, r) + + +def _update_pair_results_async( + session: Session, project: MasteringProject +) -> Operation: + """Update record pair predictions according to the latest pair-matching model + + Args: + project: Tamr Mastering project + """ + r = session.post(str(project.url) + "/recordPairsWithPredictions:refresh") + return operation._from_response(project.url.instance, r) + + +def _update_high_impact_pairs_async( + session: Session, project: MasteringProject +) -> Operation: + """Produce new high-impact pairs according to the latest pair-matching model + + Args: + project: Tamr Mastering project + """ + r = session.post(str(project.url) + "/highImpactPairs:refresh") + return operation._from_response(project.url.instance, r) + + +def _update_cluster_results_async( + session: Session, project: MasteringProject +) -> Operation: + """Generate clusters based on the latest pair-matching model + + Args: + project: Tamr Mastering project + """ + r = session.post(str(project.url) + "/recordClusters:refresh") + return operation._from_response(project.url.instance, r) + + +def _publish_clusters_async(session: Session, project: MasteringProject) -> Operation: + """Publish current record clusters Args: project: Tamr Mastering project """ - r = session.post(str(project.url) + "publishedClustersWithData:refresh") + r = session.post(str(project.url) + "/publishedClustersWithData:refresh") return operation._from_response(project.url.instance, r) From 7a199ccb7c659967f0bcc0c375a659445d8ec385 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 21 Aug 2020 09:05:03 -0400 Subject: [PATCH 547/632] Remove redundant docstrings for hidden async functions. --- tamr_client/mastering/_mastering.py | 40 ----------------------------- 1 file changed, 40 deletions(-) diff --git a/tamr_client/mastering/_mastering.py b/tamr_client/mastering/_mastering.py index f8c94840..debac452 100644 --- a/tamr_client/mastering/_mastering.py +++ b/tamr_client/mastering/_mastering.py @@ -97,41 +97,21 @@ def publish_clusters(session: Session, project: MasteringProject) -> Operation: def _update_unified_dataset_async( session: Session, project: MasteringProject ) -> Operation: - """Apply changes to the unified dataset - - Args: - project: Tamr Mastering project - """ unified_dataset = unified.from_project(session, project.url.instance, project) return unified._apply_changes_async(session, unified_dataset) def _estimate_pairs_async(session: Session, project: MasteringProject) -> Operation: - """Update the estimated pair counts - - Args: - project: Tamr Mastering project - """ r = session.post(str(project.url) + "/estimatedPairCounts:refresh") return operation._from_response(project.url.instance, r) def _generate_pairs_async(session: Session, project: MasteringProject) -> Operation: - """Generate pairs according to the binning model - - Args: - project: Tamr Mastering project - """ r = session.post(str(project.url) + "/recordPairs:refresh") return operation._from_response(project.url.instance, r) def _apply_feedback_async(session: Session, project: MasteringProject) -> Operation: - """Train the pair-matching model according to verified labels - - Args: - project: Tamr Mastering project - """ r = session.post(str(project.url) + "/recordPairsWithPredictions/model:refresh") return operation._from_response(project.url.instance, r) @@ -139,11 +119,6 @@ def _apply_feedback_async(session: Session, project: MasteringProject) -> Operat def _update_pair_results_async( session: Session, project: MasteringProject ) -> Operation: - """Update record pair predictions according to the latest pair-matching model - - Args: - project: Tamr Mastering project - """ r = session.post(str(project.url) + "/recordPairsWithPredictions:refresh") return operation._from_response(project.url.instance, r) @@ -151,11 +126,6 @@ def _update_pair_results_async( def _update_high_impact_pairs_async( session: Session, project: MasteringProject ) -> Operation: - """Produce new high-impact pairs according to the latest pair-matching model - - Args: - project: Tamr Mastering project - """ r = session.post(str(project.url) + "/highImpactPairs:refresh") return operation._from_response(project.url.instance, r) @@ -163,20 +133,10 @@ def _update_high_impact_pairs_async( def _update_cluster_results_async( session: Session, project: MasteringProject ) -> Operation: - """Generate clusters based on the latest pair-matching model - - Args: - project: Tamr Mastering project - """ r = session.post(str(project.url) + "/recordClusters:refresh") return operation._from_response(project.url.instance, r) def _publish_clusters_async(session: Session, project: MasteringProject) -> Operation: - """Publish current record clusters - - Args: - project: Tamr Mastering project - """ r = session.post(str(project.url) + "/publishedClustersWithData:refresh") return operation._from_response(project.url.instance, r) From e7961789917885b77912dde750464a61c5983445 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 21 Aug 2020 09:47:51 -0400 Subject: [PATCH 548/632] Remove unneeded instance parameter from unified.from_project. --- tamr_client/categorization/project.py | 4 +--- tamr_client/dataset/unified.py | 8 ++------ tamr_client/mastering/_mastering.py | 2 +- tests/tamr_client/dataset/test_unified.py | 6 ++---- 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/tamr_client/categorization/project.py b/tamr_client/categorization/project.py index cdfa82a3..1e4d115b 100644 --- a/tamr_client/categorization/project.py +++ b/tamr_client/categorization/project.py @@ -76,9 +76,7 @@ def manual_labels( _dataset.NotFound: If no dataset could be found at the specified URL Ambiguous: If multiple targets match dataset name """ - unified_dataset = unified.from_project( - session=session, instance=instance, project=project - ) + unified_dataset = unified.from_project(session=session, project=project) labels_dataset_name = unified_dataset.name + "_manual_categorizations" datasets_url = URL(instance=instance, path="datasets") r = session.get( diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index 0d19eeec..f82535b9 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -5,7 +5,6 @@ from tamr_client import operation, response from tamr_client._types import ( - Instance, JsonDict, Operation, Project, @@ -24,15 +23,12 @@ class NotFound(TamrClientException): pass -def from_project( - session: Session, instance: Instance, project: Project -) -> UnifiedDataset: +def from_project(session: Session, project: Project) -> UnifiedDataset: """Get unified dataset of a project Fetches the unified dataset of a given project from Tamr server Args: - instance: Tamr instance containing this dataset project: Tamr project of this Unified Dataset Raises: @@ -40,7 +36,7 @@ def from_project( Corresponds to a 404 HTTP error. requests.HTTPError: If any other HTTP error is encountered. """ - url = URL(instance=instance, path=f"{project.url.path}/unifiedDataset") + url = URL(instance=project.url.instance, path=f"{project.url.path}/unifiedDataset") return _from_url(session, url) diff --git a/tamr_client/mastering/_mastering.py b/tamr_client/mastering/_mastering.py index debac452..c1dc0697 100644 --- a/tamr_client/mastering/_mastering.py +++ b/tamr_client/mastering/_mastering.py @@ -97,7 +97,7 @@ def publish_clusters(session: Session, project: MasteringProject) -> Operation: def _update_unified_dataset_async( session: Session, project: MasteringProject ) -> Operation: - unified_dataset = unified.from_project(session, project.url.instance, project) + unified_dataset = unified.from_project(session, project) return unified._apply_changes_async(session, unified_dataset) diff --git a/tests/tamr_client/dataset/test_unified.py b/tests/tamr_client/dataset/test_unified.py index 7aafd0ed..b19df66e 100644 --- a/tests/tamr_client/dataset/test_unified.py +++ b/tests/tamr_client/dataset/test_unified.py @@ -8,14 +8,13 @@ @responses.activate def test_from_project(): s = fake.session() - instance = fake.instance() project = fake.mastering_project() dataset_json = utils.load_json("dataset.json") url = tc.URL(path="projects/1/unifiedDataset") responses.add(responses.GET, str(url), json=dataset_json) - unified_dataset = tc.dataset.unified.from_project(s, instance, project) + unified_dataset = tc.dataset.unified.from_project(s, project) assert unified_dataset.name == "dataset 1 name" assert unified_dataset.description == "dataset 1 description" assert unified_dataset.key_attribute_names == ("tamr_id",) @@ -24,14 +23,13 @@ def test_from_project(): @responses.activate def test_from_project_dataset_not_found(): s = fake.session() - instance = fake.instance() project = fake.mastering_project() url = tc.URL(path="projects/1/unifiedDataset") responses.add(responses.GET, str(url), status=404) with pytest.raises(tc.dataset.unified.NotFound): - tc.dataset.unified.from_project(s, instance, project) + tc.dataset.unified.from_project(s, project) @responses.activate From 3be8d08796314662e9bf1ebb4ca71fe1e01147f3 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 21 Aug 2020 10:32:03 -0400 Subject: [PATCH 549/632] Update tc.unified tests. --- tests/tamr_client/dataset/test_unified.py | 40 ++++++++----------- .../test_apply_changes_async.json | 33 +++++++++++++++ .../test_unified/test_from_project.json | 34 ++++++++++++++++ .../test_from_project_dataset_not_found.json | 11 +++++ 4 files changed, 94 insertions(+), 24 deletions(-) create mode 100644 tests/tamr_client/fake_json/dataset/test_unified/test_apply_changes_async.json create mode 100644 tests/tamr_client/fake_json/dataset/test_unified/test_from_project.json create mode 100644 tests/tamr_client/fake_json/dataset/test_unified/test_from_project_dataset_not_found.json diff --git a/tests/tamr_client/dataset/test_unified.py b/tests/tamr_client/dataset/test_unified.py index b19df66e..618125d2 100644 --- a/tests/tamr_client/dataset/test_unified.py +++ b/tests/tamr_client/dataset/test_unified.py @@ -1,48 +1,40 @@ import pytest -import responses import tamr_client as tc -from tests.tamr_client import fake, utils +from tests.tamr_client import fake -@responses.activate +@fake.json def test_from_project(): s = fake.session() project = fake.mastering_project() - dataset_json = utils.load_json("dataset.json") - url = tc.URL(path="projects/1/unifiedDataset") - responses.add(responses.GET, str(url), json=dataset_json) - unified_dataset = tc.dataset.unified.from_project(s, project) assert unified_dataset.name == "dataset 1 name" assert unified_dataset.description == "dataset 1 description" assert unified_dataset.key_attribute_names == ("tamr_id",) -@responses.activate +@fake.json def test_from_project_dataset_not_found(): s = fake.session() project = fake.mastering_project() - url = tc.URL(path="projects/1/unifiedDataset") - responses.add(responses.GET, str(url), status=404) - with pytest.raises(tc.dataset.unified.NotFound): tc.dataset.unified.from_project(s, project) -@responses.activate -def test_apply_changes(): +@fake.json +def test_apply_changes_async(): s = fake.session() - dataset_json = utils.load_json("dataset.json") - dataset_url = tc.URL(path="projects/1/unifiedDataset") - unified_dataset = tc.dataset.unified._from_json(dataset_url, dataset_json) - - operation_json = utils.load_json("operation_pending.json") - operation_url = tc.URL(path="operations/1") - url = tc.URL(path="projects/1/unifiedDataset:refresh") - responses.add(responses.POST, str(url), json=operation_json) - - response = tc.dataset.unified._apply_changes_async(s, unified_dataset) - assert response == tc.operation._from_json(operation_url, operation_json) + unified_dataset = fake.unified_dataset() + + op = tc.dataset.unified._apply_changes_async(s, unified_dataset) + assert op.type == "SPARK" + assert op.description == "operation 1 description" + assert op.status == { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark", + } diff --git a/tests/tamr_client/fake_json/dataset/test_unified/test_apply_changes_async.json b/tests/tamr_client/fake_json/dataset/test_unified/test_apply_changes_async.json new file mode 100644 index 00000000..0552bed0 --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_unified/test_apply_changes_async.json @@ -0,0 +1,33 @@ +[ + { + "request": { + "method": "POST", + "path": "projects/1/unifiedDataset:refresh" + }, + "response": { + "status": 200, + "json": { + "id": "1", + "type": "SPARK", + "description": "operation 1 description", + "status": { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark" + }, + "created": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 modified version" + }, + "relativeId": "operations/1" + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/dataset/test_unified/test_from_project.json b/tests/tamr_client/fake_json/dataset/test_unified/test_from_project.json new file mode 100644 index 00000000..4ff5b270 --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_unified/test_from_project.json @@ -0,0 +1,34 @@ +[ + { + "request": { + "method": "GET", + "path": "projects/1/unifiedDataset" + }, + "response": { + "status": 200, + "json": { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "dataset 1 name", + "description": "dataset 1 description", + "version": "dataset 1 version", + "keyAttributeNames": [ + "tamr_id" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/dataset/test_unified/test_from_project_dataset_not_found.json b/tests/tamr_client/fake_json/dataset/test_unified/test_from_project_dataset_not_found.json new file mode 100644 index 00000000..53328d69 --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_unified/test_from_project_dataset_not_found.json @@ -0,0 +1,11 @@ +[ + { + "request": { + "method": "GET", + "path": "projects/1/unifiedDataset" + }, + "response": { + "status": 404 + } + } +] \ No newline at end of file From df9610fa82a713eb2669e7268bcf0fbdb771cacd Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Fri, 21 Aug 2020 11:12:47 -0400 Subject: [PATCH 550/632] Add refresh dataset functionality. --- tamr_client/dataset/_dataset.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/tamr_client/dataset/_dataset.py b/tamr_client/dataset/_dataset.py index 8dbf15fa..5e781aac 100644 --- a/tamr_client/dataset/_dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -5,8 +5,8 @@ from dataclasses import replace from typing import Tuple -from tamr_client import response -from tamr_client._types import Attribute, Dataset, Instance, JsonDict, Session, URL +from tamr_client import response, operation +from tamr_client._types import Attribute, Dataset, Instance, JsonDict, Operation, Session, URL from tamr_client.attribute import _from_json as _attribute_from_json from tamr_client.exception import TamrClientException @@ -102,3 +102,28 @@ def attributes(session: Session, dataset: Dataset) -> Tuple[Attribute, ...]: attr = _attribute_from_json(attr_url, attr_json) attrs.append(attr) return tuple(attrs) + + +def refresh(session: Session, dataset: Dataset) -> Operation: + """Applies changes to the unified dataset and waits for the operation to complete + + Args: + unified_dataset: The Unified Dataset which will be committed + """ + op = _refresh_async(session, dataset) + return operation.wait(session, op) + + +def _refresh_async( + session: Session, dataset: Dataset +) -> Operation: + """Applies changes to the unified dataset + + Args: + unified_dataset: The Unified Dataset which will be committed + """ + r = session.post( + str(dataset.url) + ":refresh", + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + return operation._from_response(dataset.url.instance, r) From 0e6192a9dbaf009a5ced153e227346c412886e29 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 21 Aug 2020 11:02:59 -0400 Subject: [PATCH 551/632] Add testing of async mastering op functions. --- tamr_client/mastering/__init__.py | 8 ++ .../test_apply_feedback_async.json | 33 +++++ .../test_estimate_pairs_async.json | 33 +++++ .../test_generate_pairs_async.json | 33 +++++ .../test_publish_clusters_async.json | 33 +++++ .../test_update_cluster_results_async.json | 33 +++++ .../test_update_high_impact_pairs_async.json | 33 +++++ .../test_update_pair_results_async.json | 33 +++++ tests/tamr_client/mastering/test_mastering.py | 114 ++++++++++++++++++ 9 files changed, 353 insertions(+) create mode 100644 tests/tamr_client/fake_json/mastering/test_mastering/test_apply_feedback_async.json create mode 100644 tests/tamr_client/fake_json/mastering/test_mastering/test_estimate_pairs_async.json create mode 100644 tests/tamr_client/fake_json/mastering/test_mastering/test_generate_pairs_async.json create mode 100644 tests/tamr_client/fake_json/mastering/test_mastering/test_publish_clusters_async.json create mode 100644 tests/tamr_client/fake_json/mastering/test_mastering/test_update_cluster_results_async.json create mode 100644 tests/tamr_client/fake_json/mastering/test_mastering/test_update_high_impact_pairs_async.json create mode 100644 tests/tamr_client/fake_json/mastering/test_mastering/test_update_pair_results_async.json create mode 100644 tests/tamr_client/mastering/test_mastering.py diff --git a/tamr_client/mastering/__init__.py b/tamr_client/mastering/__init__.py index 455f552b..9d836375 100644 --- a/tamr_client/mastering/__init__.py +++ b/tamr_client/mastering/__init__.py @@ -4,6 +4,14 @@ """ from tamr_client.mastering import project from tamr_client.mastering._mastering import ( + _apply_feedback_async, + _estimate_pairs_async, + _generate_pairs_async, + _publish_clusters_async, + _update_cluster_results_async, + _update_high_impact_pairs_async, + _update_pair_results_async, + _update_unified_dataset_async, apply_feedback, estimate_pairs, generate_pairs, diff --git a/tests/tamr_client/fake_json/mastering/test_mastering/test_apply_feedback_async.json b/tests/tamr_client/fake_json/mastering/test_mastering/test_apply_feedback_async.json new file mode 100644 index 00000000..1fe30561 --- /dev/null +++ b/tests/tamr_client/fake_json/mastering/test_mastering/test_apply_feedback_async.json @@ -0,0 +1,33 @@ +[ + { + "request": { + "method": "POST", + "path": "projects/1/recordPairsWithPredictions/model:refresh" + }, + "response": { + "status": 200, + "json": { + "id": "1", + "type": "SPARK", + "description": "Materialize views to Elastic", + "status": { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark" + }, + "created": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 modified version" + }, + "relativeId": "operations/1" + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/mastering/test_mastering/test_estimate_pairs_async.json b/tests/tamr_client/fake_json/mastering/test_mastering/test_estimate_pairs_async.json new file mode 100644 index 00000000..fca4e1bf --- /dev/null +++ b/tests/tamr_client/fake_json/mastering/test_mastering/test_estimate_pairs_async.json @@ -0,0 +1,33 @@ +[ + { + "request": { + "method": "POST", + "path": "projects/1/estimatedPairCounts:refresh" + }, + "response": { + "status": 200, + "json": { + "id": "1", + "type": "SPARK", + "description": "operation 1 description", + "status": { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark" + }, + "created": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 modified version" + }, + "relativeId": "operations/1" + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/mastering/test_mastering/test_generate_pairs_async.json b/tests/tamr_client/fake_json/mastering/test_mastering/test_generate_pairs_async.json new file mode 100644 index 00000000..4c0590a2 --- /dev/null +++ b/tests/tamr_client/fake_json/mastering/test_mastering/test_generate_pairs_async.json @@ -0,0 +1,33 @@ +[ + { + "request": { + "method": "POST", + "path": "projects/1/recordPairs:refresh" + }, + "response": { + "status": 200, + "json": { + "id": "1", + "type": "SPARK", + "description": "Materialize views to Elastic", + "status": { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark" + }, + "created": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 modified version" + }, + "relativeId": "operations/1" + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/mastering/test_mastering/test_publish_clusters_async.json b/tests/tamr_client/fake_json/mastering/test_mastering/test_publish_clusters_async.json new file mode 100644 index 00000000..f937de85 --- /dev/null +++ b/tests/tamr_client/fake_json/mastering/test_mastering/test_publish_clusters_async.json @@ -0,0 +1,33 @@ +[ + { + "request": { + "method": "POST", + "path": "projects/1/publishedClustersWithData:refresh" + }, + "response": { + "status": 200, + "json": { + "id": "1", + "type": "SPARK", + "description": "operation 1 description", + "status": { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark" + }, + "created": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 modified version" + }, + "relativeId": "operations/1" + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/mastering/test_mastering/test_update_cluster_results_async.json b/tests/tamr_client/fake_json/mastering/test_mastering/test_update_cluster_results_async.json new file mode 100644 index 00000000..2e2af14a --- /dev/null +++ b/tests/tamr_client/fake_json/mastering/test_mastering/test_update_cluster_results_async.json @@ -0,0 +1,33 @@ +[ + { + "request": { + "method": "POST", + "path": "projects/1/recordClusters:refresh" + }, + "response": { + "status": 200, + "json": { + "id": "1", + "type": "SPARK", + "description": "Materialize views to Elastic", + "status": { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark" + }, + "created": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 modified version" + }, + "relativeId": "operations/1" + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/mastering/test_mastering/test_update_high_impact_pairs_async.json b/tests/tamr_client/fake_json/mastering/test_mastering/test_update_high_impact_pairs_async.json new file mode 100644 index 00000000..68a8fa8a --- /dev/null +++ b/tests/tamr_client/fake_json/mastering/test_mastering/test_update_high_impact_pairs_async.json @@ -0,0 +1,33 @@ +[ + { + "request": { + "method": "POST", + "path": "projects/1/highImpactPairs:refresh" + }, + "response": { + "status": 200, + "json": { + "id": "1", + "type": "SPARK", + "description": "Materialize views to Elastic", + "status": { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark" + }, + "created": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 modified version" + }, + "relativeId": "operations/1" + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/mastering/test_mastering/test_update_pair_results_async.json b/tests/tamr_client/fake_json/mastering/test_mastering/test_update_pair_results_async.json new file mode 100644 index 00000000..89f6bcca --- /dev/null +++ b/tests/tamr_client/fake_json/mastering/test_mastering/test_update_pair_results_async.json @@ -0,0 +1,33 @@ +[ + { + "request": { + "method": "POST", + "path": "projects/1/recordPairsWithPredictions:refresh" + }, + "response": { + "status": 200, + "json": { + "id": "1", + "type": "SPARK", + "description": "Materialize views to Elastic", + "status": { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark" + }, + "created": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 modified version" + }, + "relativeId": "operations/1" + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/mastering/test_mastering.py b/tests/tamr_client/mastering/test_mastering.py new file mode 100644 index 00000000..e887f99b --- /dev/null +++ b/tests/tamr_client/mastering/test_mastering.py @@ -0,0 +1,114 @@ +import tamr_client as tc +from tests.tamr_client import fake + + +@fake.json +def test_estimate_pairs_async(): + s = fake.session() + project = fake.mastering_project() + + op = tc.mastering._estimate_pairs_async(s, project) + assert op.type == "SPARK" + assert op.description == "operation 1 description" + assert op.status == { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark", + } + + +@fake.json +def test_generate_pairs_async(): + s = fake.session() + project = fake.mastering_project() + + op = tc.mastering._generate_pairs_async(s, project) + assert op.type == "SPARK" + assert op.description == "Materialize views to Elastic" + assert op.status == { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark", + } + + +@fake.json +def test_apply_feedback_async(): + s = fake.session() + project = fake.mastering_project() + + op = tc.mastering._apply_feedback_async(s, project) + assert op.type == "SPARK" + assert op.description == "Materialize views to Elastic" + assert op.status == { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark", + } + + +@fake.json +def test_update_pair_results_async(): + s = fake.session() + project = fake.mastering_project() + + op = tc.mastering._update_pair_results_async(s, project) + assert op.type == "SPARK" + assert op.description == "Materialize views to Elastic" + assert op.status == { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark", + } + + +@fake.json +def test_update_high_impact_pairs_async(): + s = fake.session() + project = fake.mastering_project() + + op = tc.mastering._update_high_impact_pairs_async(s, project) + assert op.type == "SPARK" + assert op.description == "Materialize views to Elastic" + assert op.status == { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark", + } + + +@fake.json +def test_update_cluster_results_async(): + s = fake.session() + project = fake.mastering_project() + + op = tc.mastering._update_cluster_results_async(s, project) + assert op.type == "SPARK" + assert op.description == "Materialize views to Elastic" + assert op.status == { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark", + } + + +@fake.json +def test_publish_clusters_async(): + s = fake.session() + project = fake.mastering_project() + + op = tc.mastering._publish_clusters_async(s, project) + assert op.type == "SPARK" + assert op.description == "operation 1 description" + assert op.status == { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark", + } From 6b3f712648c4f4b0097c608f55d0160314980ced Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Fri, 21 Aug 2020 11:23:59 -0400 Subject: [PATCH 552/632] fixed linting --- tamr_client/dataset/_dataset.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tamr_client/dataset/_dataset.py b/tamr_client/dataset/_dataset.py index 5e781aac..ac872fe8 100644 --- a/tamr_client/dataset/_dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -5,8 +5,16 @@ from dataclasses import replace from typing import Tuple -from tamr_client import response, operation -from tamr_client._types import Attribute, Dataset, Instance, JsonDict, Operation, Session, URL +from tamr_client import operation, response +from tamr_client._types import ( + Attribute, + Dataset, + Instance, + JsonDict, + Operation, + Session, + URL, +) from tamr_client.attribute import _from_json as _attribute_from_json from tamr_client.exception import TamrClientException @@ -114,9 +122,7 @@ def refresh(session: Session, dataset: Dataset) -> Operation: return operation.wait(session, op) -def _refresh_async( - session: Session, dataset: Dataset -) -> Operation: +def _refresh_async(session: Session, dataset: Dataset) -> Operation: """Applies changes to the unified dataset Args: From 16b20c7e31d8153f86b716237add7ab30ddb19ba Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Fri, 21 Aug 2020 11:27:15 -0400 Subject: [PATCH 553/632] docstrings --- tamr_client/dataset/_dataset.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tamr_client/dataset/_dataset.py b/tamr_client/dataset/_dataset.py index ac872fe8..2b63a421 100644 --- a/tamr_client/dataset/_dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -113,20 +113,20 @@ def attributes(session: Session, dataset: Dataset) -> Tuple[Attribute, ...]: def refresh(session: Session, dataset: Dataset) -> Operation: - """Applies changes to the unified dataset and waits for the operation to complete + """Refreshes a dataset and waits for the operation to complete Args: - unified_dataset: The Unified Dataset which will be committed + dataset: A Tamr dataset which will be refreshed """ op = _refresh_async(session, dataset) return operation.wait(session, op) def _refresh_async(session: Session, dataset: Dataset) -> Operation: - """Applies changes to the unified dataset + """Refreshes the dataset Args: - unified_dataset: The Unified Dataset which will be committed + dataset: The Dataset which will be refreshed """ r = session.post( str(dataset.url) + ":refresh", From 61bbb543f02f31296dbadea459dee5530c4bc137 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 21 Aug 2020 11:25:11 -0400 Subject: [PATCH 554/632] Add line to module docstring refering to async functions. --- tamr_client/mastering/_mastering.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tamr_client/mastering/_mastering.py b/tamr_client/mastering/_mastering.py index c1dc0697..7eb3cc47 100644 --- a/tamr_client/mastering/_mastering.py +++ b/tamr_client/mastering/_mastering.py @@ -3,6 +3,9 @@ See https://docs.tamr.com/docs/overall-workflow-mastering The terminology used here is consistent with Tamr UI terminology + +Asynchronous versions of each function can be found with the suffix `_async` and may be of +interest to power users """ from tamr_client import operation from tamr_client._types import MasteringProject, Operation, Session From 660949e8275e6a8e60e9853a67443de0b5335b5e Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Fri, 21 Aug 2020 11:52:15 -0400 Subject: [PATCH 555/632] add refresh to dataset.__init__.py --- tamr_client/dataset/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tamr_client/dataset/__init__.py b/tamr_client/dataset/__init__.py index e0de52d6..31bba7c5 100644 --- a/tamr_client/dataset/__init__.py +++ b/tamr_client/dataset/__init__.py @@ -1,2 +1,7 @@ from tamr_client.dataset import dataframe, record, unified -from tamr_client.dataset._dataset import attributes, from_resource_id, NotFound +from tamr_client.dataset._dataset import( + attributes, + from_resource_id, + NotFound, + refresh, +) From 0dae67d41c11cedd4a3fc29fe06aa959b8d7f8a8 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Fri, 21 Aug 2020 11:53:22 -0400 Subject: [PATCH 556/632] hoist Ambiguous exception --- tamr_client/dataset/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tamr_client/dataset/__init__.py b/tamr_client/dataset/__init__.py index 31bba7c5..60380825 100644 --- a/tamr_client/dataset/__init__.py +++ b/tamr_client/dataset/__init__.py @@ -3,5 +3,6 @@ attributes, from_resource_id, NotFound, + Ambiguous, refresh, ) From e4a1a7760aa784e64f9157cec5695879c6daed13 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Fri, 21 Aug 2020 13:41:18 -0400 Subject: [PATCH 557/632] added testing --- tamr_client/dataset/__init__.py | 5 +-- tests/tamr_client/dataset/test_dataset.py | 17 ++++++++++ .../test_dataset/test_materialize_async.json | 34 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 tests/tamr_client/fake_json/dataset/test_dataset/test_materialize_async.json diff --git a/tamr_client/dataset/__init__.py b/tamr_client/dataset/__init__.py index 60380825..a981b787 100644 --- a/tamr_client/dataset/__init__.py +++ b/tamr_client/dataset/__init__.py @@ -1,8 +1,9 @@ from tamr_client.dataset import dataframe, record, unified -from tamr_client.dataset._dataset import( +from tamr_client.dataset._dataset import ( + _refresh_async, + Ambiguous, attributes, from_resource_id, NotFound, - Ambiguous, refresh, ) diff --git a/tests/tamr_client/dataset/test_dataset.py b/tests/tamr_client/dataset/test_dataset.py index 726e4ad7..ae93359d 100644 --- a/tests/tamr_client/dataset/test_dataset.py +++ b/tests/tamr_client/dataset/test_dataset.py @@ -38,3 +38,20 @@ def test_attributes(): geom = attrs[1] assert geom.name == "geom" assert isinstance(geom.type, tc.attribute.type.Record) + + +@fake.json +def test_materialize_async(): + s = fake.session() + dataset = fake.dataset() + + op = tc.dataset._refresh_async(s, dataset) + + assert op.type == "SPARK" + assert op.description == "Materialize views to Elastic" + assert op.status == { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark", + } diff --git a/tests/tamr_client/fake_json/dataset/test_dataset/test_materialize_async.json b/tests/tamr_client/fake_json/dataset/test_dataset/test_materialize_async.json new file mode 100644 index 00000000..d28ed3dd --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataset/test_materialize_async.json @@ -0,0 +1,34 @@ +[ + { + "request": { + "method": "POST", + "path": "datasets/1:refresh" + }, + "response": { + "status": 200, + "json": { + "id": "1", + "type": "SPARK", + "description": "Materialize views to Elastic", + "status": { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark" + }, + "created": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 modified version" + }, + "relativeId": "operations/1" + + } + } + } +] From e0dfe65bb4cce9e68d737747b08df43bb5d7fa31 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Fri, 21 Aug 2020 13:43:35 -0400 Subject: [PATCH 558/632] remove unnecessary headers from materialize and unified.apply_changes calls --- tamr_client/dataset/_dataset.py | 1 - tamr_client/dataset/unified.py | 1 - 2 files changed, 2 deletions(-) diff --git a/tamr_client/dataset/_dataset.py b/tamr_client/dataset/_dataset.py index 2b63a421..68bdb135 100644 --- a/tamr_client/dataset/_dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -130,6 +130,5 @@ def _refresh_async(session: Session, dataset: Dataset) -> Operation: """ r = session.post( str(dataset.url) + ":refresh", - headers={"Content-Type": "application/json", "Accept": "application/json"}, ) return operation._from_response(dataset.url.instance, r) diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index f82535b9..69f62277 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -96,6 +96,5 @@ def _apply_changes_async( """ r = session.post( str(unified_dataset.url) + ":refresh", - headers={"Content-Type": "application/json", "Accept": "application/json"}, ) return operation._from_response(unified_dataset.url.instance, r) From 47cce86a5e4c0f351b85f442671f5a5186a0d113 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Fri, 21 Aug 2020 13:47:50 -0400 Subject: [PATCH 559/632] removed hoist of dataset.Ambiguous exception --- tamr_client/dataset/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tamr_client/dataset/__init__.py b/tamr_client/dataset/__init__.py index a981b787..549e3464 100644 --- a/tamr_client/dataset/__init__.py +++ b/tamr_client/dataset/__init__.py @@ -1,7 +1,6 @@ from tamr_client.dataset import dataframe, record, unified from tamr_client.dataset._dataset import ( _refresh_async, - Ambiguous, attributes, from_resource_id, NotFound, From ee50e1e4cb28c509dd3b982e3efca8feb11abe9a Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Fri, 21 Aug 2020 13:48:25 -0400 Subject: [PATCH 560/632] Update tamr_client/dataset/_dataset.py Co-authored-by: Pedro Cattori --- tamr_client/dataset/_dataset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_client/dataset/_dataset.py b/tamr_client/dataset/_dataset.py index 68bdb135..40c33deb 100644 --- a/tamr_client/dataset/_dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -112,7 +112,7 @@ def attributes(session: Session, dataset: Dataset) -> Tuple[Attribute, ...]: return tuple(attrs) -def refresh(session: Session, dataset: Dataset) -> Operation: +def materialize(session: Session, dataset: Dataset) -> Operation: """Refreshes a dataset and waits for the operation to complete Args: From 5eb2415875450077d2c136bbef62b344e973193f Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Fri, 21 Aug 2020 13:48:36 -0400 Subject: [PATCH 561/632] Update tamr_client/dataset/_dataset.py Co-authored-by: Pedro Cattori --- tamr_client/dataset/_dataset.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tamr_client/dataset/_dataset.py b/tamr_client/dataset/_dataset.py index 40c33deb..6c4eff9c 100644 --- a/tamr_client/dataset/_dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -113,7 +113,9 @@ def attributes(session: Session, dataset: Dataset) -> Tuple[Attribute, ...]: def materialize(session: Session, dataset: Dataset) -> Operation: - """Refreshes a dataset and waits for the operation to complete + """Materialize a dataset + + Materializing consists of updating the dataset (including records) in persistent storage (HBase) based on upstream changes to data. Args: dataset: A Tamr dataset which will be refreshed From 2d39617a9ee5d657aa13ae88cc4a18d1f7519430 Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Fri, 21 Aug 2020 13:51:17 -0400 Subject: [PATCH 562/632] rename "refresh" to "materialize" --- tamr_client/dataset/_dataset.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tamr_client/dataset/_dataset.py b/tamr_client/dataset/_dataset.py index 6c4eff9c..1dedd2ca 100644 --- a/tamr_client/dataset/_dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -120,12 +120,14 @@ def materialize(session: Session, dataset: Dataset) -> Operation: Args: dataset: A Tamr dataset which will be refreshed """ - op = _refresh_async(session, dataset) + op = _materialize_async(session, dataset) return operation.wait(session, op) -def _refresh_async(session: Session, dataset: Dataset) -> Operation: - """Refreshes the dataset +def _materialize_async(session: Session, dataset: Dataset) -> Operation: + """Materialize a dataset + + Materializing consists of updating the dataset (including records) in persistent storage (HBase) based on upstream changes to data. Args: dataset: The Dataset which will be refreshed From 79a34903f68695dd24fba1b3b93044c0bcb6a27d Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Fri, 21 Aug 2020 13:52:22 -0400 Subject: [PATCH 563/632] fix test_materialize --- tests/tamr_client/dataset/test_dataset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tamr_client/dataset/test_dataset.py b/tests/tamr_client/dataset/test_dataset.py index ae93359d..41fce9c2 100644 --- a/tests/tamr_client/dataset/test_dataset.py +++ b/tests/tamr_client/dataset/test_dataset.py @@ -45,7 +45,7 @@ def test_materialize_async(): s = fake.session() dataset = fake.dataset() - op = tc.dataset._refresh_async(s, dataset) + op = tc.dataset._materialize_async(s, dataset) assert op.type == "SPARK" assert op.description == "Materialize views to Elastic" From ffb225f13b117e4ff7c170e1fef3642c28438e40 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Fri, 21 Aug 2020 13:57:25 -0400 Subject: [PATCH 564/632] fixed refresh -> materialize and all linting/formatting/testing errors --- tamr_client/dataset/__init__.py | 4 ++-- tamr_client/dataset/_dataset.py | 10 +++------- tamr_client/dataset/unified.py | 4 +--- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/tamr_client/dataset/__init__.py b/tamr_client/dataset/__init__.py index 549e3464..4381481b 100644 --- a/tamr_client/dataset/__init__.py +++ b/tamr_client/dataset/__init__.py @@ -1,8 +1,8 @@ from tamr_client.dataset import dataframe, record, unified from tamr_client.dataset._dataset import ( - _refresh_async, + _materialize_async, attributes, from_resource_id, + materialize, NotFound, - refresh, ) diff --git a/tamr_client/dataset/_dataset.py b/tamr_client/dataset/_dataset.py index 1dedd2ca..602d42d0 100644 --- a/tamr_client/dataset/_dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -114,11 +114,10 @@ def attributes(session: Session, dataset: Dataset) -> Tuple[Attribute, ...]: def materialize(session: Session, dataset: Dataset) -> Operation: """Materialize a dataset - Materializing consists of updating the dataset (including records) in persistent storage (HBase) based on upstream changes to data. Args: - dataset: A Tamr dataset which will be refreshed + dataset: A Tamr dataset which will be materialized """ op = _materialize_async(session, dataset) return operation.wait(session, op) @@ -126,13 +125,10 @@ def materialize(session: Session, dataset: Dataset) -> Operation: def _materialize_async(session: Session, dataset: Dataset) -> Operation: """Materialize a dataset - Materializing consists of updating the dataset (including records) in persistent storage (HBase) based on upstream changes to data. Args: - dataset: The Dataset which will be refreshed + dataset: The Dataset which will be materialized """ - r = session.post( - str(dataset.url) + ":refresh", - ) + r = session.post(str(dataset.url) + ":refresh",) return operation._from_response(dataset.url.instance, r) diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index 69f62277..0daa177f 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -94,7 +94,5 @@ def _apply_changes_async( Args: unified_dataset: The Unified Dataset which will be committed """ - r = session.post( - str(unified_dataset.url) + ":refresh", - ) + r = session.post(str(unified_dataset.url) + ":refresh",) return operation._from_response(unified_dataset.url.instance, r) From f305b1a717539225bcd5ff97d028b6407a416b28 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Fri, 21 Aug 2020 14:48:36 -0400 Subject: [PATCH 565/632] added to docs and changelog --- CHANGELOG.md | 1 + docs/beta/dataset/dataset.rst | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8ba1369..a1991f29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - [#434](https://github.com/Datatamer/tamr-client/pull/434) Added `tc.instance.version` function to get Tamr Version - [#435](https://github.com/Datatamer/tamr-client/pull/435) Now able to create projects of the following type in Tamr: Categorization, Mastering, Schema Mapping - [#440](https://github.com/Datatamer/tamr-client/pull/440) Added functions for initiating basic mastering workflow operations in `tc.mastering` + - [#443](https://github.com/Datatamer/tamr-client/pull/443) Added function to materialize datasets. **NEW FEATURES** - [#383](https://github.com/Datatamer/tamr-client/issues/383) Now able to create an Operation from Job resource id diff --git a/docs/beta/dataset/dataset.rst b/docs/beta/dataset/dataset.rst index 0df9bd26..d884b6ea 100644 --- a/docs/beta/dataset/dataset.rst +++ b/docs/beta/dataset/dataset.rst @@ -5,6 +5,7 @@ Dataset .. autofunction:: tamr_client.dataset.from_resource_id .. autofunction:: tamr_client.dataset.attributes +.. autofunction:: tamr_client.dataset.materialize Exceptions ---------- From a132138b12f9c53238766805248adcee0d6597a6 Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Fri, 21 Aug 2020 14:55:39 -0400 Subject: [PATCH 566/632] Update tamr_client/dataset/unified.py Co-authored-by: Pedro Cattori --- tamr_client/dataset/unified.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index 0daa177f..74345215 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -94,5 +94,5 @@ def _apply_changes_async( Args: unified_dataset: The Unified Dataset which will be committed """ - r = session.post(str(unified_dataset.url) + ":refresh",) + r = session.post(str(unified_dataset.url) + ":refresh") return operation._from_response(unified_dataset.url.instance, r) From aaa34e2815f425760b1bd07127827b01b728a91d Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Fri, 21 Aug 2020 14:55:56 -0400 Subject: [PATCH 567/632] sounds good. wasn't 100% sure. Co-authored-by: Pedro Cattori --- tamr_client/dataset/_dataset.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tamr_client/dataset/_dataset.py b/tamr_client/dataset/_dataset.py index 602d42d0..2d182b16 100644 --- a/tamr_client/dataset/_dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -124,11 +124,5 @@ def materialize(session: Session, dataset: Dataset) -> Operation: def _materialize_async(session: Session, dataset: Dataset) -> Operation: - """Materialize a dataset - Materializing consists of updating the dataset (including records) in persistent storage (HBase) based on upstream changes to data. - - Args: - dataset: The Dataset which will be materialized - """ r = session.post(str(dataset.url) + ":refresh",) return operation._from_response(dataset.url.instance, r) From ae7a6190a2251d31f973f9d5a7170ac5dc097f97 Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Fri, 21 Aug 2020 15:13:49 -0400 Subject: [PATCH 568/632] Update tamr_client/dataset/_dataset.py Co-authored-by: skalish <39866163+skalish@users.noreply.github.com> --- tamr_client/dataset/_dataset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_client/dataset/_dataset.py b/tamr_client/dataset/_dataset.py index 2d182b16..4a323acd 100644 --- a/tamr_client/dataset/_dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -113,7 +113,7 @@ def attributes(session: Session, dataset: Dataset) -> Tuple[Attribute, ...]: def materialize(session: Session, dataset: Dataset) -> Operation: - """Materialize a dataset + """Materialize a dataset and wait for the operation to complete Materializing consists of updating the dataset (including records) in persistent storage (HBase) based on upstream changes to data. Args: From 4fad6ba9c3e16237dcc1682a67541dfa23418ad0 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 21 Aug 2020 14:31:06 -0400 Subject: [PATCH 569/632] Add dataset.by_name function with tests and docs. --- docs/beta/dataset/dataset.rst | 4 ++ tamr_client/categorization/project.py | 25 ++------ tamr_client/dataset/__init__.py | 2 + tamr_client/dataset/_dataset.py | 31 ++++++++++ .../categorization/test_project.py | 5 +- tests/tamr_client/dataset/test_dataset.py | 29 +++++++++ .../test_project/test_manual_labels.json | 32 ---------- .../dataset/test_dataset/test_by_name.json | 36 +++++++++++ .../test_by_name_dataset_ambiguous.json | 59 +++++++++++++++++++ .../test_by_name_dataset_not_found.json | 12 ++++ .../fake_json/test_project/test_by_name.json | 36 +++++++++++ .../test_by_name_project_ambiguous.json | 59 +++++++++++++++++++ .../test_by_name_project_not_found.json | 12 ++++ 13 files changed, 287 insertions(+), 55 deletions(-) create mode 100644 tests/tamr_client/fake_json/dataset/test_dataset/test_by_name.json create mode 100644 tests/tamr_client/fake_json/dataset/test_dataset/test_by_name_dataset_ambiguous.json create mode 100644 tests/tamr_client/fake_json/dataset/test_dataset/test_by_name_dataset_not_found.json create mode 100644 tests/tamr_client/fake_json/test_project/test_by_name.json create mode 100644 tests/tamr_client/fake_json/test_project/test_by_name_project_ambiguous.json create mode 100644 tests/tamr_client/fake_json/test_project/test_by_name_project_not_found.json diff --git a/docs/beta/dataset/dataset.rst b/docs/beta/dataset/dataset.rst index d884b6ea..a6993857 100644 --- a/docs/beta/dataset/dataset.rst +++ b/docs/beta/dataset/dataset.rst @@ -4,6 +4,7 @@ Dataset .. autoclass:: tamr_client.Dataset .. autofunction:: tamr_client.dataset.from_resource_id +.. autofunction:: tamr_client.dataset.by_name .. autofunction:: tamr_client.dataset.attributes .. autofunction:: tamr_client.dataset.materialize @@ -12,3 +13,6 @@ Exceptions .. autoclass:: tamr_client.dataset.NotFound :no-inherited-members: + +.. autoclass:: tamr_client.dataset.Ambiguous + :no-inherited-members: diff --git a/tamr_client/categorization/project.py b/tamr_client/categorization/project.py index 1e4d115b..1634d0d3 100644 --- a/tamr_client/categorization/project.py +++ b/tamr_client/categorization/project.py @@ -46,7 +46,7 @@ def create( Project created in Tamr Raises: - AlreadyExists: If a project with these specifications already exists + attribute.AlreadyExists: If a project with these specifications already exists requests.HTTPError: If any other HTTP error is encountered """ return project._create( @@ -60,34 +60,21 @@ def create( ) -def manual_labels( - session: Session, instance: Instance, project: CategorizationProject -) -> Dataset: +def manual_labels(session: Session, project: CategorizationProject) -> Dataset: """Get manual labels from a Categorization project. Args: - instance: Tamr instance containing project project: Tamr project containing labels Returns: Dataset containing manual labels Raises: - _dataset.NotFound: If no dataset could be found at the specified URL - Ambiguous: If multiple targets match dataset name + dataset.NotFound: If no dataset could be found at the specified URL + dataset.Ambiguous: If multiple targets match dataset name """ unified_dataset = unified.from_project(session=session, project=project) labels_dataset_name = unified_dataset.name + "_manual_categorizations" - datasets_url = URL(instance=instance, path="datasets") - r = session.get( - url=str(datasets_url), params={"filter": f"name=={labels_dataset_name}"} + return _dataset.by_name( + session=session, instance=project.url.instance, name=labels_dataset_name ) - matches = r.json() - if len(matches) == 0: - raise _dataset.NotFound(str(r.url)) - if len(matches) > 1: - raise _dataset.Ambiguous(str(r.url)) - - dataset_path = matches[0]["relativeId"] - dataset_url = URL(instance=instance, path=dataset_path) - return _dataset._from_url(session=session, url=dataset_url) diff --git a/tamr_client/dataset/__init__.py b/tamr_client/dataset/__init__.py index 4381481b..c31e9405 100644 --- a/tamr_client/dataset/__init__.py +++ b/tamr_client/dataset/__init__.py @@ -1,7 +1,9 @@ from tamr_client.dataset import dataframe, record, unified from tamr_client.dataset._dataset import ( _materialize_async, + Ambiguous, attributes, + by_name, from_resource_id, materialize, NotFound, diff --git a/tamr_client/dataset/_dataset.py b/tamr_client/dataset/_dataset.py index 4a323acd..ac58dedd 100644 --- a/tamr_client/dataset/_dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -51,6 +51,37 @@ def from_resource_id(session: Session, instance: Instance, id: str) -> Dataset: return _from_url(session, url) +def by_name(session: Session, instance: Instance, name: str) -> Dataset: + """Get dataset by name + + Fetches dataset from Tamr server + + Args: + instance: Tamr instance containing this dataset + name: Dataset name + + Raises: + dataset.NotFound: If no dataset could be found with that name. + dataset.Ambiguous: If multiple targets match dataset name. + requests.HTTPError: If any other HTTP error is encountered. + """ + r = session.get( + url=str(URL(instance=instance, path="datasets")), + params={"filter": f"name=={name}"}, + ) + + # Check that exactly one dataset is returned + matches = r.json() + if len(matches) == 0: + raise NotFound(str(r.url)) + if len(matches) > 1: + raise Ambiguous(str(r.url)) + + # Make Dataset from response + url = URL(instance=instance, path=matches[0]["relativeId"]) + return _from_json(url=url, data=matches[0]) + + def _from_url(session: Session, url: URL) -> Dataset: """Get dataset by URL diff --git a/tests/tamr_client/categorization/test_project.py b/tests/tamr_client/categorization/test_project.py index b4a8e020..3c717f18 100644 --- a/tests/tamr_client/categorization/test_project.py +++ b/tests/tamr_client/categorization/test_project.py @@ -5,9 +5,6 @@ @fake.json def test_manual_labels(): s = fake.session() - instance = fake.instance() project = fake.categorization_project() - tc.categorization.project.manual_labels( - session=s, instance=instance, project=project - ) + tc.categorization.project.manual_labels(session=s, project=project) diff --git a/tests/tamr_client/dataset/test_dataset.py b/tests/tamr_client/dataset/test_dataset.py index 41fce9c2..4917965e 100644 --- a/tests/tamr_client/dataset/test_dataset.py +++ b/tests/tamr_client/dataset/test_dataset.py @@ -24,6 +24,35 @@ def test_from_resource_id_dataset_not_found(): tc.dataset.from_resource_id(s, instance, "1") +@fake.json +def test_by_name(): + s = fake.session() + instance = fake.instance() + + dataset = tc.dataset.by_name(s, instance, "dataset 1 name") + assert dataset.name == "dataset 1 name" + assert dataset.description == "dataset 1 description" + assert dataset.key_attribute_names == ("tamr_id",) + + +@fake.json +def test_by_name_dataset_not_found(): + s = fake.session() + instance = fake.instance() + + with pytest.raises(tc.dataset.NotFound): + tc.dataset.by_name(s, instance, "missing dataset") + + +@fake.json +def test_by_name_dataset_ambiguous(): + s = fake.session() + instance = fake.instance() + + with pytest.raises(tc.dataset.Ambiguous): + tc.dataset.by_name(s, instance, "ambiguous dataset") + + @fake.json def test_attributes(): s = fake.session() diff --git a/tests/tamr_client/fake_json/categorization/test_project/test_manual_labels.json b/tests/tamr_client/fake_json/categorization/test_project/test_manual_labels.json index 5b4effb4..e95e0565 100644 --- a/tests/tamr_client/fake_json/categorization/test_project/test_manual_labels.json +++ b/tests/tamr_client/fake_json/categorization/test_project/test_manual_labels.json @@ -66,37 +66,5 @@ } ] } - }, - { - "request": { - "method": "GET", - "path": "datasets/167" - }, - "response": { - "status": 200, - "json": { - "id": "unify://unified-data/v1/datasets/167", - "name": "Party_Categorization_Unified_Dataset_manual_categorizations", - "description": "Manual categorizations", - "version": "2992", - "keyAttributeNames": [ - "recordId" - ], - "tags": [], - "created": { - "username": "afsana.afzal", - "time": "2020-06-01T20:49:46.549Z", - "version": "57920" - }, - "lastModified": { - "username": "workflow.bot", - "time": "2020-06-18T15:32:44.631Z", - "version": "150069" - }, - "relativeId": "datasets/167", - "upstreamDatasetIds": [], - "externalId": "Party_Categorization_Unified_Dataset_manual_categorizations" - } - } } ] diff --git a/tests/tamr_client/fake_json/dataset/test_dataset/test_by_name.json b/tests/tamr_client/fake_json/dataset/test_dataset/test_by_name.json new file mode 100644 index 00000000..f3908e62 --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataset/test_by_name.json @@ -0,0 +1,36 @@ +[ + { + "request": { + "method": "GET", + "path": "datasets?filter=name==dataset 1 name" + }, + "response": { + "status": 200, + "json": [ + { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "dataset 1 name", + "description": "dataset 1 description", + "version": "dataset 1 version", + "keyAttributeNames": [ + "tamr_id" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] + } + ] + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/dataset/test_dataset/test_by_name_dataset_ambiguous.json b/tests/tamr_client/fake_json/dataset/test_dataset/test_by_name_dataset_ambiguous.json new file mode 100644 index 00000000..a23428eb --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataset/test_by_name_dataset_ambiguous.json @@ -0,0 +1,59 @@ +[ + { + "request": { + "method": "GET", + "path": "datasets?filter=name==ambiguous dataset" + }, + "response": { + "status": 200, + "json": [ + { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "ambiguous dataset", + "description": "description", + "version": "version", + "keyAttributeNames": [ + "tamr_id" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] + }, + { + "id": "unify://unified-data/v1/datasets/2", + "externalId": "number 2", + "name": "ambiguous dataset", + "description": "description", + "version": "version", + "keyAttributeNames": [ + "tamr_id" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 2 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 2 modified version" + }, + "relativeId": "datasets/2", + "upstreamDatasetIds": [] + } + ] + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/dataset/test_dataset/test_by_name_dataset_not_found.json b/tests/tamr_client/fake_json/dataset/test_dataset/test_by_name_dataset_not_found.json new file mode 100644 index 00000000..588865c7 --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataset/test_by_name_dataset_not_found.json @@ -0,0 +1,12 @@ +[ + { + "request": { + "method": "GET", + "path": "datasets?filter=name==missing dataset" + }, + "response": { + "status": 200, + "json": [] + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/test_project/test_by_name.json b/tests/tamr_client/fake_json/test_project/test_by_name.json new file mode 100644 index 00000000..f3908e62 --- /dev/null +++ b/tests/tamr_client/fake_json/test_project/test_by_name.json @@ -0,0 +1,36 @@ +[ + { + "request": { + "method": "GET", + "path": "datasets?filter=name==dataset 1 name" + }, + "response": { + "status": 200, + "json": [ + { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "dataset 1 name", + "description": "dataset 1 description", + "version": "dataset 1 version", + "keyAttributeNames": [ + "tamr_id" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] + } + ] + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/test_project/test_by_name_project_ambiguous.json b/tests/tamr_client/fake_json/test_project/test_by_name_project_ambiguous.json new file mode 100644 index 00000000..a23428eb --- /dev/null +++ b/tests/tamr_client/fake_json/test_project/test_by_name_project_ambiguous.json @@ -0,0 +1,59 @@ +[ + { + "request": { + "method": "GET", + "path": "datasets?filter=name==ambiguous dataset" + }, + "response": { + "status": 200, + "json": [ + { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "ambiguous dataset", + "description": "description", + "version": "version", + "keyAttributeNames": [ + "tamr_id" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] + }, + { + "id": "unify://unified-data/v1/datasets/2", + "externalId": "number 2", + "name": "ambiguous dataset", + "description": "description", + "version": "version", + "keyAttributeNames": [ + "tamr_id" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 2 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 2 modified version" + }, + "relativeId": "datasets/2", + "upstreamDatasetIds": [] + } + ] + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/test_project/test_by_name_project_not_found.json b/tests/tamr_client/fake_json/test_project/test_by_name_project_not_found.json new file mode 100644 index 00000000..588865c7 --- /dev/null +++ b/tests/tamr_client/fake_json/test_project/test_by_name_project_not_found.json @@ -0,0 +1,12 @@ +[ + { + "request": { + "method": "GET", + "path": "datasets?filter=name==missing dataset" + }, + "response": { + "status": 200, + "json": [] + } + } +] \ No newline at end of file From d8c97a12ba3baff5c8fb0e7803c5625f0001309a Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 21 Aug 2020 15:02:18 -0400 Subject: [PATCH 570/632] Add project.by_name function with tests and docs. --- docs/beta/project.rst | 4 ++ tamr_client/project.py | 36 +++++++++++++ .../fake_json/test_project/test_by_name.json | 28 +++++----- .../test_by_name_project_ambiguous.json | 54 ++++++++----------- .../test_by_name_project_not_found.json | 2 +- tests/tamr_client/test_project.py | 28 ++++++++++ 6 files changed, 104 insertions(+), 48 deletions(-) diff --git a/docs/beta/project.rst b/docs/beta/project.rst index c2e3b860..1a8ec072 100644 --- a/docs/beta/project.rst +++ b/docs/beta/project.rst @@ -2,9 +2,13 @@ Project ======= .. autofunction:: tamr_client.project.from_resource_id +.. autofunction:: tamr_client.project.by_name Exceptions ---------- .. autoclass:: tamr_client.project.NotFound :no-inherited-members: + +.. autoclass:: tamr_client.project.Ambiguous + :no-inherited-members: \ No newline at end of file diff --git a/tamr_client/project.py b/tamr_client/project.py index 55d89170..47040474 100644 --- a/tamr_client/project.py +++ b/tamr_client/project.py @@ -15,6 +15,12 @@ class NotFound(TamrClientException): pass +class Ambiguous(TamrClientException): + """Raised when referencing a project by name that matches multiple possible targets.""" + + pass + + class AlreadyExists(TamrClientException): """Raised when a project with these specifications already exists.""" @@ -38,6 +44,36 @@ def from_resource_id(session: Session, instance: Instance, id: str) -> Project: return _from_url(session, url) +def by_name(session: Session, instance: Instance, name: str) -> Project: + """Get project by name + Fetches project from Tamr server. + + Args: + instance: Tamr instance containing this project + name: Project name + + Raises: + project.NotFound: If no project could be found with that name. + project.Ambiguous: If multiple targets match project name. + requests.HTTPError: If any other HTTP error is encountered. + """ + r = session.get( + url=str(URL(instance=instance, path="projects")), + params={"filter": f"name=={name}"}, + ) + + # Check that exactly one project is returned + matches = r.json() + if len(matches) == 0: + raise NotFound(str(r.url)) + if len(matches) > 1: + raise Ambiguous(str(r.url)) + + # Make Project from response + url = URL(instance=instance, path=matches[0]["relativeId"]) + return _from_json(url=url, data=matches[0]) + + def _from_url(session: Session, url: URL) -> Project: """Get project by URL. Fetches project from Tamr server. diff --git a/tests/tamr_client/fake_json/test_project/test_by_name.json b/tests/tamr_client/fake_json/test_project/test_by_name.json index f3908e62..e665ad93 100644 --- a/tests/tamr_client/fake_json/test_project/test_by_name.json +++ b/tests/tamr_client/fake_json/test_project/test_by_name.json @@ -2,33 +2,29 @@ { "request": { "method": "GET", - "path": "datasets?filter=name==dataset 1 name" + "path": "projects?filter=name==proj" }, "response": { "status": 200, "json": [ { - "id": "unify://unified-data/v1/datasets/1", - "externalId": "number 1", - "name": "dataset 1 name", - "description": "dataset 1 description", - "version": "dataset 1 version", - "keyAttributeNames": [ - "tamr_id" - ], - "tags": [], + "id": "unify://unified-data/v1/projects/1", + "name": "proj", + "description": "Mastering Project", + "type": "DEDUP", + "unifiedDatasetName": "proj_unified_dataset", "created": { "username": "admin", - "time": "2018-09-10T16:06:20.636Z", - "version": "dataset 1 created version" + "time": "2020-04-03T14:14:18.752Z", + "version": "18" }, "lastModified": { "username": "admin", - "time": "2018-09-10T16:06:20.851Z", - "version": "dataset 1 modified version" + "time": "2020-04-03T14:14:20.115Z", + "version": "19" }, - "relativeId": "datasets/1", - "upstreamDatasetIds": [] + "relativeId": "projects/1", + "externalId": "58bdbe72-3c08-427d-97bd-45b16d92c79c" } ] } diff --git a/tests/tamr_client/fake_json/test_project/test_by_name_project_ambiguous.json b/tests/tamr_client/fake_json/test_project/test_by_name_project_ambiguous.json index a23428eb..c40d9b1b 100644 --- a/tests/tamr_client/fake_json/test_project/test_by_name_project_ambiguous.json +++ b/tests/tamr_client/fake_json/test_project/test_by_name_project_ambiguous.json @@ -2,56 +2,48 @@ { "request": { "method": "GET", - "path": "datasets?filter=name==ambiguous dataset" + "path": "projects?filter=name==ambiguous proj" }, "response": { "status": 200, "json": [ { - "id": "unify://unified-data/v1/datasets/1", - "externalId": "number 1", - "name": "ambiguous dataset", - "description": "description", - "version": "version", - "keyAttributeNames": [ - "tamr_id" - ], - "tags": [], + "id": "unify://unified-data/v1/projects/1", + "name": "ambiguous proj", + "description": "Mastering Project", + "type": "DEDUP", + "unifiedDatasetName": "proj_unified_dataset", "created": { "username": "admin", - "time": "2018-09-10T16:06:20.636Z", - "version": "dataset 1 created version" + "time": "2020-04-03T14:14:18.752Z", + "version": "18" }, "lastModified": { "username": "admin", - "time": "2018-09-10T16:06:20.851Z", - "version": "dataset 1 modified version" + "time": "2020-04-03T14:14:20.115Z", + "version": "19" }, - "relativeId": "datasets/1", - "upstreamDatasetIds": [] + "relativeId": "projects/1", + "externalId": "58bdbe72-3c08-427d-97bd-45b16d92c79c" }, { - "id": "unify://unified-data/v1/datasets/2", - "externalId": "number 2", - "name": "ambiguous dataset", - "description": "description", - "version": "version", - "keyAttributeNames": [ - "tamr_id" - ], - "tags": [], + "id": "unify://unified-data/v1/projects/2", + "name": "ambiguous proj", + "description": "Mastering Project", + "type": "DEDUP", + "unifiedDatasetName": "proj_unified_dataset", "created": { "username": "admin", - "time": "2018-09-10T16:06:20.636Z", - "version": "dataset 2 created version" + "time": "2020-04-03T14:14:18.752Z", + "version": "18" }, "lastModified": { "username": "admin", - "time": "2018-09-10T16:06:20.851Z", - "version": "dataset 2 modified version" + "time": "2020-04-03T14:14:20.115Z", + "version": "19" }, - "relativeId": "datasets/2", - "upstreamDatasetIds": [] + "relativeId": "projects/2", + "externalId": "39abcd72-3c08-427d-9d7b-d92c45b1679c" } ] } diff --git a/tests/tamr_client/fake_json/test_project/test_by_name_project_not_found.json b/tests/tamr_client/fake_json/test_project/test_by_name_project_not_found.json index 588865c7..d4dca382 100644 --- a/tests/tamr_client/fake_json/test_project/test_by_name_project_not_found.json +++ b/tests/tamr_client/fake_json/test_project/test_by_name_project_not_found.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "datasets?filter=name==missing dataset" + "path": "projects?filter=name==missing proj" }, "response": { "status": 200, diff --git a/tests/tamr_client/test_project.py b/tests/tamr_client/test_project.py index eb209228..b80f851b 100644 --- a/tests/tamr_client/test_project.py +++ b/tests/tamr_client/test_project.py @@ -33,3 +33,31 @@ def test_from_resource_id_not_found(): with pytest.raises(tc.project.NotFound): tc.project.from_resource_id(s, instance, "1") + + +@fake.json +def test_by_name(): + s = fake.session() + instance = fake.instance() + + project = tc.project.by_name(s, instance, "proj") + assert project.name == "proj" + assert project.description == "Mastering Project" + + +@fake.json +def test_by_name_project_not_found(): + s = fake.session() + instance = fake.instance() + + with pytest.raises(tc.project.NotFound): + tc.project.by_name(s, instance, "missing project") + + +@fake.json +def test_by_name_project_ambiguous(): + s = fake.session() + instance = fake.instance() + + with pytest.raises(tc.project.Ambiguous): + tc.project.by_name(s, instance, "ambiguous project") From c06dce762a035466932a468bb13c49ee97ab324f Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 21 Aug 2020 15:49:43 -0400 Subject: [PATCH 571/632] Update changelog. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1991f29..36d3d833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - [#435](https://github.com/Datatamer/tamr-client/pull/435) Now able to create projects of the following type in Tamr: Categorization, Mastering, Schema Mapping - [#440](https://github.com/Datatamer/tamr-client/pull/440) Added functions for initiating basic mastering workflow operations in `tc.mastering` - [#443](https://github.com/Datatamer/tamr-client/pull/443) Added function to materialize datasets. + - [#445](https://github.com/Datatamer/tamr-client/pull/445) Added functions for getting projects and datasets by name via `tc.project.by_name` and `tc.dataset.by_name` **NEW FEATURES** - [#383](https://github.com/Datatamer/tamr-client/issues/383) Now able to create an Operation from Job resource id From 57dd2d3b243d985cc1277c14506dae1b6cc5b7f6 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 26 Aug 2020 11:12:01 -0400 Subject: [PATCH 572/632] Rename from_resource_id to by_resource_id. --- CHANGELOG.md | 1 + docs/beta/attribute/attribute.rst | 2 +- docs/beta/dataset/dataset.rst | 2 +- docs/beta/operation.rst | 2 +- docs/beta/project.rst | 2 +- tamr_client/attribute/__init__.py | 2 +- tamr_client/attribute/_attribute.py | 2 +- tamr_client/dataset/__init__.py | 2 +- tamr_client/dataset/_dataset.py | 2 +- tamr_client/operation.py | 4 +--- tamr_client/project.py | 2 +- tamr_client/transformations.py | 9 ++++----- tests/tamr_client/attribute/test_attribute.py | 8 ++++---- tests/tamr_client/dataset/test_dataset.py | 8 ++++---- ...rom_resource_id.json => test_by_resource_id.json} | 0 ... => test_by_resource_id_attribute_not_found.json} | 0 ...rom_resource_id.json => test_by_resource_id.json} | 0 ...on => test_by_resource_id_dataset_not_found.json} | 0 ....json => test_by_resource_id_categorization.json} | 0 ...ering.json => test_by_resource_id_mastering.json} | 0 ...found.json => test_by_resource_id_not_found.json} | 0 tests/tamr_client/test_operation.py | 4 ++-- tests/tamr_client/test_project.py | 12 ++++++------ 23 files changed, 31 insertions(+), 33 deletions(-) rename tests/tamr_client/fake_json/attribute/test_attribute/{test_from_resource_id.json => test_by_resource_id.json} (100%) rename tests/tamr_client/fake_json/attribute/test_attribute/{test_from_resource_id_attribute_not_found.json => test_by_resource_id_attribute_not_found.json} (100%) rename tests/tamr_client/fake_json/dataset/test_dataset/{test_from_resource_id.json => test_by_resource_id.json} (100%) rename tests/tamr_client/fake_json/dataset/test_dataset/{test_from_resource_id_dataset_not_found.json => test_by_resource_id_dataset_not_found.json} (100%) rename tests/tamr_client/fake_json/test_project/{test_from_resource_id_categorization.json => test_by_resource_id_categorization.json} (100%) rename tests/tamr_client/fake_json/test_project/{test_from_resource_id_mastering.json => test_by_resource_id_mastering.json} (100%) rename tests/tamr_client/fake_json/test_project/{test_from_resource_id_not_found.json => test_by_resource_id_not_found.json} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36d3d833..5daf9c5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - [#440](https://github.com/Datatamer/tamr-client/pull/440) Added functions for initiating basic mastering workflow operations in `tc.mastering` - [#443](https://github.com/Datatamer/tamr-client/pull/443) Added function to materialize datasets. - [#445](https://github.com/Datatamer/tamr-client/pull/445) Added functions for getting projects and datasets by name via `tc.project.by_name` and `tc.dataset.by_name` + - Renamed functions `from_resource_id` to `by_resource_id` in `tc.attribute`, `tc.dataset`, `tc.operation`, and `tc.project` **NEW FEATURES** - [#383](https://github.com/Datatamer/tamr-client/issues/383) Now able to create an Operation from Job resource id diff --git a/docs/beta/attribute/attribute.rst b/docs/beta/attribute/attribute.rst index 50adc00d..9500568b 100644 --- a/docs/beta/attribute/attribute.rst +++ b/docs/beta/attribute/attribute.rst @@ -3,7 +3,7 @@ Attribute .. autoclass:: tamr_client.Attribute -.. autofunction:: tamr_client.attribute.from_resource_id +.. autofunction:: tamr_client.attribute.by_resource_id .. autofunction:: tamr_client.attribute.to_json .. autofunction:: tamr_client.attribute.create .. autofunction:: tamr_client.attribute.update diff --git a/docs/beta/dataset/dataset.rst b/docs/beta/dataset/dataset.rst index a6993857..b3fb5010 100644 --- a/docs/beta/dataset/dataset.rst +++ b/docs/beta/dataset/dataset.rst @@ -3,7 +3,7 @@ Dataset .. autoclass:: tamr_client.Dataset -.. autofunction:: tamr_client.dataset.from_resource_id +.. autofunction:: tamr_client.dataset.by_resource_id .. autofunction:: tamr_client.dataset.by_name .. autofunction:: tamr_client.dataset.attributes .. autofunction:: tamr_client.dataset.materialize diff --git a/docs/beta/operation.rst b/docs/beta/operation.rst index f552caca..4a339902 100644 --- a/docs/beta/operation.rst +++ b/docs/beta/operation.rst @@ -6,4 +6,4 @@ Operation .. autofunction:: tamr_client.operation.poll .. autofunction:: tamr_client.operation.wait .. autofunction:: tamr_client.operation.succeeded -.. autofunction:: tamr_client.operation.from_resource_id \ No newline at end of file +.. autofunction:: tamr_client.operation.by_resource_id \ No newline at end of file diff --git a/docs/beta/project.rst b/docs/beta/project.rst index 1a8ec072..6c2d4de8 100644 --- a/docs/beta/project.rst +++ b/docs/beta/project.rst @@ -1,7 +1,7 @@ Project ======= -.. autofunction:: tamr_client.project.from_resource_id +.. autofunction:: tamr_client.project.by_resource_id .. autofunction:: tamr_client.project.by_name Exceptions diff --git a/tamr_client/attribute/__init__.py b/tamr_client/attribute/__init__.py index 55d4b33d..45071966 100644 --- a/tamr_client/attribute/__init__.py +++ b/tamr_client/attribute/__init__.py @@ -3,9 +3,9 @@ from tamr_client.attribute._attribute import ( _from_json, AlreadyExists, + by_resource_id, create, delete, - from_resource_id, NotFound, ReservedName, to_json, diff --git a/tamr_client/attribute/_attribute.py b/tamr_client/attribute/_attribute.py index ccc422e3..4a758f3f 100644 --- a/tamr_client/attribute/_attribute.py +++ b/tamr_client/attribute/_attribute.py @@ -50,7 +50,7 @@ class ReservedName(TamrClientException): pass -def from_resource_id(session: Session, dataset: Dataset, id: str) -> Attribute: +def by_resource_id(session: Session, dataset: Dataset, id: str) -> Attribute: """Get attribute by resource ID Fetches attribute from Tamr server diff --git a/tamr_client/dataset/__init__.py b/tamr_client/dataset/__init__.py index c31e9405..b911d458 100644 --- a/tamr_client/dataset/__init__.py +++ b/tamr_client/dataset/__init__.py @@ -4,7 +4,7 @@ Ambiguous, attributes, by_name, - from_resource_id, + by_resource_id, materialize, NotFound, ) diff --git a/tamr_client/dataset/_dataset.py b/tamr_client/dataset/_dataset.py index ac58dedd..9abbdc9b 100644 --- a/tamr_client/dataset/_dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -33,7 +33,7 @@ class Ambiguous(TamrClientException): pass -def from_resource_id(session: Session, instance: Instance, id: str) -> Dataset: +def by_resource_id(session: Session, instance: Instance, id: str) -> Dataset: """Get dataset by resource ID Fetches dataset from Tamr server diff --git a/tamr_client/operation.py b/tamr_client/operation.py index 44186511..99cc57e8 100644 --- a/tamr_client/operation.py +++ b/tamr_client/operation.py @@ -68,9 +68,7 @@ def succeeded(operation: Operation) -> bool: return operation.status is not None and operation.status["state"] == "SUCCEEDED" -def from_resource_id( - session: Session, instance: Instance, resource_id: str -) -> Operation: +def by_resource_id(session: Session, instance: Instance, resource_id: str) -> Operation: """Get operation by ID Args: diff --git a/tamr_client/project.py b/tamr_client/project.py index 47040474..07fd899e 100644 --- a/tamr_client/project.py +++ b/tamr_client/project.py @@ -27,7 +27,7 @@ class AlreadyExists(TamrClientException): pass -def from_resource_id(session: Session, instance: Instance, id: str) -> Project: +def by_resource_id(session: Session, instance: Instance, id: str) -> Project: """Get project by resource ID. Fetches project from Tamr server. diff --git a/tamr_client/transformations.py b/tamr_client/transformations.py index 1734a2d8..42e6896b 100644 --- a/tamr_client/transformations.py +++ b/tamr_client/transformations.py @@ -22,8 +22,7 @@ def _input_transformation_from_json( """ dataset_resource_ids = [d["datasetId"].split("/")[-1] for d in data["datasets"]] datasets = [ - dataset.from_resource_id(session, instance, d_id) - for d_id in dataset_resource_ids + dataset.by_resource_id(session, instance, d_id) for d_id in dataset_resource_ids ] return InputTransformation(transformation=data["transformation"], datasets=datasets) @@ -83,7 +82,7 @@ def get_all(session: Session, project: Project) -> Transformations: >>> import tamr_client as tc >>> session = tc.session.from_auth('username', 'password') >>> instance = tc.instance.Instance(host="localhost", port=9100) - >>> project1 = tc.project.from_resource_id(session, instance, id='1') + >>> project1 = tc.project.by_resource_id(session, instance, id='1') >>> print(tc.transformations.get_all(session, project1)) """ r = session.get(f"{project.url}/transformations") @@ -107,8 +106,8 @@ def replace_all( >>> import tamr_client as tc >>> session = tc.session.from_auth('username', 'password') >>> instance = tc.instance.Instance(host="localhost", port=9100) - >>> project1 = tc.project.from_resource_id(session, instance, id='1') - >>> dataset3 = tc.dataset.from_resource_id(session, instance, id='3') + >>> project1 = tc.project.by_resource_id(session, instance, id='1') + >>> dataset3 = tc.dataset.by_resource_id(session, instance, id='3') >>> new_input_tx = tc.InputTransformation("SELECT *, upper(name) as name;", [dataset3]) >>> all_tx = tc.Transformations( ... input_scope=[new_input_tx], diff --git a/tests/tamr_client/attribute/test_attribute.py b/tests/tamr_client/attribute/test_attribute.py index 4dbede03..dfdbbf9b 100644 --- a/tests/tamr_client/attribute/test_attribute.py +++ b/tests/tamr_client/attribute/test_attribute.py @@ -78,7 +78,7 @@ def test_delete(): @fake.json -def test_from_resource_id(): +def test_by_resource_id(): s = fake.session() dataset = fake.dataset() @@ -93,7 +93,7 @@ def test_from_resource_id(): ] ) - attr = tc.attribute.from_resource_id(s, dataset, "attr") + attr = tc.attribute.by_resource_id(s, dataset, "attr") assert attr.name == "attr" assert not attr.is_nullable @@ -102,12 +102,12 @@ def test_from_resource_id(): @fake.json -def test_from_resource_id_attribute_not_found(): +def test_by_resource_id_attribute_not_found(): s = fake.session() dataset = fake.dataset() with pytest.raises(tc.attribute.NotFound): - tc.attribute.from_resource_id(s, dataset, "attr") + tc.attribute.by_resource_id(s, dataset, "attr") def test_create_reserved_attribute_name(): diff --git a/tests/tamr_client/dataset/test_dataset.py b/tests/tamr_client/dataset/test_dataset.py index 4917965e..eebaab5c 100644 --- a/tests/tamr_client/dataset/test_dataset.py +++ b/tests/tamr_client/dataset/test_dataset.py @@ -5,23 +5,23 @@ @fake.json -def test_from_resource_id(): +def test_by_resource_id(): s = fake.session() instance = fake.instance() - dataset = tc.dataset.from_resource_id(s, instance, "1") + dataset = tc.dataset.by_resource_id(s, instance, "1") assert dataset.name == "dataset 1 name" assert dataset.description == "dataset 1 description" assert dataset.key_attribute_names == ("tamr_id",) @fake.json -def test_from_resource_id_dataset_not_found(): +def test_by_resource_id_dataset_not_found(): s = fake.session() instance = fake.instance() with pytest.raises(tc.dataset.NotFound): - tc.dataset.from_resource_id(s, instance, "1") + tc.dataset.by_resource_id(s, instance, "1") @fake.json diff --git a/tests/tamr_client/fake_json/attribute/test_attribute/test_from_resource_id.json b/tests/tamr_client/fake_json/attribute/test_attribute/test_by_resource_id.json similarity index 100% rename from tests/tamr_client/fake_json/attribute/test_attribute/test_from_resource_id.json rename to tests/tamr_client/fake_json/attribute/test_attribute/test_by_resource_id.json diff --git a/tests/tamr_client/fake_json/attribute/test_attribute/test_from_resource_id_attribute_not_found.json b/tests/tamr_client/fake_json/attribute/test_attribute/test_by_resource_id_attribute_not_found.json similarity index 100% rename from tests/tamr_client/fake_json/attribute/test_attribute/test_from_resource_id_attribute_not_found.json rename to tests/tamr_client/fake_json/attribute/test_attribute/test_by_resource_id_attribute_not_found.json diff --git a/tests/tamr_client/fake_json/dataset/test_dataset/test_from_resource_id.json b/tests/tamr_client/fake_json/dataset/test_dataset/test_by_resource_id.json similarity index 100% rename from tests/tamr_client/fake_json/dataset/test_dataset/test_from_resource_id.json rename to tests/tamr_client/fake_json/dataset/test_dataset/test_by_resource_id.json diff --git a/tests/tamr_client/fake_json/dataset/test_dataset/test_from_resource_id_dataset_not_found.json b/tests/tamr_client/fake_json/dataset/test_dataset/test_by_resource_id_dataset_not_found.json similarity index 100% rename from tests/tamr_client/fake_json/dataset/test_dataset/test_from_resource_id_dataset_not_found.json rename to tests/tamr_client/fake_json/dataset/test_dataset/test_by_resource_id_dataset_not_found.json diff --git a/tests/tamr_client/fake_json/test_project/test_from_resource_id_categorization.json b/tests/tamr_client/fake_json/test_project/test_by_resource_id_categorization.json similarity index 100% rename from tests/tamr_client/fake_json/test_project/test_from_resource_id_categorization.json rename to tests/tamr_client/fake_json/test_project/test_by_resource_id_categorization.json diff --git a/tests/tamr_client/fake_json/test_project/test_from_resource_id_mastering.json b/tests/tamr_client/fake_json/test_project/test_by_resource_id_mastering.json similarity index 100% rename from tests/tamr_client/fake_json/test_project/test_from_resource_id_mastering.json rename to tests/tamr_client/fake_json/test_project/test_by_resource_id_mastering.json diff --git a/tests/tamr_client/fake_json/test_project/test_from_resource_id_not_found.json b/tests/tamr_client/fake_json/test_project/test_by_resource_id_not_found.json similarity index 100% rename from tests/tamr_client/fake_json/test_project/test_from_resource_id_not_found.json rename to tests/tamr_client/fake_json/test_project/test_by_resource_id_not_found.json diff --git a/tests/tamr_client/test_operation.py b/tests/tamr_client/test_operation.py index 1b08b09a..b021852a 100644 --- a/tests/tamr_client/test_operation.py +++ b/tests/tamr_client/test_operation.py @@ -78,7 +78,7 @@ def test_operation_from_response_noop(): @responses.activate -def test_from_resource_id(): +def test_by_resource_id(): s = fake.session() instance = fake.instance() url = tc.URL(path="operations/1") @@ -87,7 +87,7 @@ def test_from_resource_id(): responses.add(responses.GET, str(url), json=operation_json) resource_id = "1" - op = tc.operation.from_resource_id(s, instance, resource_id) + op = tc.operation.by_resource_id(s, instance, resource_id) assert op.url == url assert op.type == operation_json["type"] assert op.description == operation_json["description"] diff --git a/tests/tamr_client/test_project.py b/tests/tamr_client/test_project.py index b80f851b..1a62b847 100644 --- a/tests/tamr_client/test_project.py +++ b/tests/tamr_client/test_project.py @@ -5,34 +5,34 @@ @fake.json -def test_from_resource_id_mastering(): +def test_by_resource_id_mastering(): s = fake.session() instance = fake.instance() - project = tc.project.from_resource_id(s, instance, "1") + project = tc.project.by_resource_id(s, instance, "1") assert isinstance(project, tc.MasteringProject) assert project.name == "proj" assert project.description == "Mastering Project" @fake.json -def test_from_resource_id_categorization(): +def test_by_resource_id_categorization(): s = fake.session() instance = fake.instance() - project = tc.project.from_resource_id(s, instance, "2") + project = tc.project.by_resource_id(s, instance, "2") assert isinstance(project, tc.CategorizationProject) assert project.name == "Party Categorization" assert project.description == "Categorizes organization at the Party/Domestic level" @fake.json -def test_from_resource_id_not_found(): +def test_by_resource_id_not_found(): s = fake.session() instance = fake.instance() with pytest.raises(tc.project.NotFound): - tc.project.from_resource_id(s, instance, "1") + tc.project.by_resource_id(s, instance, "1") @fake.json From 75662ef631fd3d43d41efd6ea5dce936ed8e091d Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 26 Aug 2020 11:22:03 -0400 Subject: [PATCH 573/632] Rename _from_url to _by_url. --- tamr_client/attribute/_attribute.py | 4 ++-- tamr_client/dataset/_dataset.py | 4 ++-- tamr_client/dataset/unified.py | 4 ++-- tamr_client/operation.py | 4 ++-- tamr_client/project.py | 6 +++--- tests/tamr_client/test_operation.py | 4 ++-- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/tamr_client/attribute/_attribute.py b/tamr_client/attribute/_attribute.py index 4a758f3f..62b7df7b 100644 --- a/tamr_client/attribute/_attribute.py +++ b/tamr_client/attribute/_attribute.py @@ -65,10 +65,10 @@ def by_resource_id(session: Session, dataset: Dataset, id: str) -> Attribute: requests.HTTPError: If any other HTTP error is encountered. """ url = replace(dataset.url, path=dataset.url.path + f"/attributes/{id}") - return _from_url(session, url) + return _by_url(session, url) -def _from_url(session: Session, url: URL) -> Attribute: +def _by_url(session: Session, url: URL) -> Attribute: """Get attribute by URL Fetches attribute from Tamr server diff --git a/tamr_client/dataset/_dataset.py b/tamr_client/dataset/_dataset.py index 9abbdc9b..93589de0 100644 --- a/tamr_client/dataset/_dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -48,7 +48,7 @@ def by_resource_id(session: Session, instance: Instance, id: str) -> Dataset: requests.HTTPError: If any other HTTP error is encountered. """ url = URL(instance=instance, path=f"datasets/{id}") - return _from_url(session, url) + return _by_url(session, url) def by_name(session: Session, instance: Instance, name: str) -> Dataset: @@ -82,7 +82,7 @@ def by_name(session: Session, instance: Instance, name: str) -> Dataset: return _from_json(url=url, data=matches[0]) -def _from_url(session: Session, url: URL) -> Dataset: +def _by_url(session: Session, url: URL) -> Dataset: """Get dataset by URL Fetches dataset from Tamr server diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index 74345215..a3b32cd9 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -37,10 +37,10 @@ def from_project(session: Session, project: Project) -> UnifiedDataset: requests.HTTPError: If any other HTTP error is encountered. """ url = URL(instance=project.url.instance, path=f"{project.url.path}/unifiedDataset") - return _from_url(session, url) + return _by_url(session, url) -def _from_url(session: Session, url: URL) -> UnifiedDataset: +def _by_url(session: Session, url: URL) -> UnifiedDataset: """Get dataset by URL Fetches dataset from Tamr server diff --git a/tamr_client/operation.py b/tamr_client/operation.py index 99cc57e8..01b4c0c3 100644 --- a/tamr_client/operation.py +++ b/tamr_client/operation.py @@ -28,7 +28,7 @@ def poll(session: Session, operation: Operation) -> Operation: Args: operation: Operation to be polled. """ - return _from_url(session, operation.url) + return _by_url(session, operation.url) def wait( @@ -119,7 +119,7 @@ def _from_response(instance: Instance, response: requests.Response) -> Operation return _from_json(_url, resource_json) -def _from_url(session: Session, url: URL) -> Operation: +def _by_url(session: Session, url: URL) -> Operation: """Get operation by URL Fetches operation from Tamr server diff --git a/tamr_client/project.py b/tamr_client/project.py index 07fd899e..ba23b8c0 100644 --- a/tamr_client/project.py +++ b/tamr_client/project.py @@ -41,7 +41,7 @@ def by_resource_id(session: Session, instance: Instance, id: str) -> Project: requests.HTTPError: If any other HTTP error is encountered. """ url = URL(instance=instance, path=f"projects/{id}") - return _from_url(session, url) + return _by_url(session, url) def by_name(session: Session, instance: Instance, name: str) -> Project: @@ -74,7 +74,7 @@ def by_name(session: Session, instance: Instance, name: str) -> Project: return _from_json(url=url, data=matches[0]) -def _from_url(session: Session, url: URL) -> Project: +def _by_url(session: Session, url: URL) -> Project: """Get project by URL. Fetches project from Tamr server. @@ -156,4 +156,4 @@ def _create( project_path = data["relativeId"] project_url = URL(instance=instance, path=str(project_path)) - return _from_url(session=session, url=project_url) + return _by_url(session=session, url=project_url) diff --git a/tests/tamr_client/test_operation.py b/tests/tamr_client/test_operation.py index b021852a..f42be25d 100644 --- a/tests/tamr_client/test_operation.py +++ b/tests/tamr_client/test_operation.py @@ -17,14 +17,14 @@ def test_operation_from_json(): @responses.activate -def test_operation_from_url(): +def test_operation_by_url(): s = fake.session() url = tc.URL(path="operations/1") operation_json = utils.load_json("operation_succeeded.json") responses.add(responses.GET, str(url), json=operation_json) - op = tc.operation._from_url(s, url) + op = tc.operation._by_url(s, url) assert op.url == url assert op.type == operation_json["type"] assert op.description == operation_json["description"] From 76078c9014ece7b7fd7c6cb97cb3183ed4eda95e Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 21 Aug 2020 17:59:38 -0400 Subject: [PATCH 574/632] Add categorization workflow functions with unit tests and docs. --- docs/beta/categorization.md | 1 + docs/beta/categorization/categorization.rst | 6 ++ tamr_client/categorization/__init__.py | 8 +++ tamr_client/categorization/_categorization.py | 67 +++++++++++++++++++ .../categorization/test_categorization.py | 34 ++++++++++ .../test_apply_feedback_async.json | 33 +++++++++ .../test_update_results_async.json | 33 +++++++++ 7 files changed, 182 insertions(+) create mode 100644 docs/beta/categorization/categorization.rst create mode 100644 tamr_client/categorization/_categorization.py create mode 100644 tests/tamr_client/categorization/test_categorization.py create mode 100644 tests/tamr_client/fake_json/categorization/test_categorization/test_apply_feedback_async.json create mode 100644 tests/tamr_client/fake_json/categorization/test_categorization/test_update_results_async.json diff --git a/docs/beta/categorization.md b/docs/beta/categorization.md index 0a56c07c..3b884bb5 100644 --- a/docs/beta/categorization.md +++ b/docs/beta/categorization.md @@ -1,3 +1,4 @@ # Categorization + * [Categorization](/beta/categorization/categorization) * [Project](/beta/categorization/project) diff --git a/docs/beta/categorization/categorization.rst b/docs/beta/categorization/categorization.rst new file mode 100644 index 00000000..dfaaec1b --- /dev/null +++ b/docs/beta/categorization/categorization.rst @@ -0,0 +1,6 @@ +Categorization +============== + +.. autofunction:: tamr_client.categorization.update_unified_dataset +.. autofunction:: tamr_client.categorization.apply_feedback +.. autofunction:: tamr_client.categorization.update_results diff --git a/tamr_client/categorization/__init__.py b/tamr_client/categorization/__init__.py index 61f375da..d24789f9 100644 --- a/tamr_client/categorization/__init__.py +++ b/tamr_client/categorization/__init__.py @@ -3,3 +3,11 @@ See https://docs.tamr.com/docs/overall-workflow-classification """ from tamr_client.categorization import project +from tamr_client.categorization._categorization import ( + _apply_feedback_async, + _update_results_async, + _update_unified_dataset_async, + apply_feedback, + update_results, + update_unified_dataset, +) diff --git a/tamr_client/categorization/_categorization.py b/tamr_client/categorization/_categorization.py new file mode 100644 index 00000000..8854e75e --- /dev/null +++ b/tamr_client/categorization/_categorization.py @@ -0,0 +1,67 @@ +""" +Tamr - Categorization +See https://docs.tamr.com/docs/overall-workflow-classification + +The terminology used here is consistent with Tamr UI terminology + +Asynchronous versions of each function can be found with the suffix `_async` and may be of +interest to power users +""" +from tamr_client import operation +from tamr_client._types import CategorizationProject, Operation, Session +from tamr_client.dataset import unified + + +def update_unified_dataset( + session: Session, project: CategorizationProject +) -> Operation: + """Apply changes to the unified dataset and wait for the operation to complete + + Args: + project: Tamr Categorization project + """ + op = _update_unified_dataset_async(session, project) + return operation.wait(session, op) + + +def apply_feedback(session: Session, project: CategorizationProject) -> Operation: + """Train the categorization model according to verified labels and wait for the + operation to complete + + Args: + project: Tamr Categorization project + """ + op = _apply_feedback_async(session, project) + return operation.wait(session, op) + + +def update_results(session: Session, project: CategorizationProject) -> Operation: + """Generate classifications based on the latest categorization model and wait for the + operation to complete + + Args: + project: Tamr Categorization project + """ + op = _update_results_async(session, project) + return operation.wait(session, op) + + +def _update_unified_dataset_async( + session: Session, project: CategorizationProject +) -> Operation: + unified_dataset = unified.from_project(session, project) + return unified._apply_changes_async(session, unified_dataset) + + +def _apply_feedback_async( + session: Session, project: CategorizationProject +) -> Operation: + r = session.post(str(project.url) + "/categorizations/model:refresh") + return operation._from_response(project.url.instance, r) + + +def _update_results_async( + session: Session, project: CategorizationProject +) -> Operation: + r = session.post(str(project.url) + "/categorizations:refresh") + return operation._from_response(project.url.instance, r) diff --git a/tests/tamr_client/categorization/test_categorization.py b/tests/tamr_client/categorization/test_categorization.py new file mode 100644 index 00000000..c6be4990 --- /dev/null +++ b/tests/tamr_client/categorization/test_categorization.py @@ -0,0 +1,34 @@ +import tamr_client as tc +from tests.tamr_client import fake + + +@fake.json +def test_apply_feedback_async(): + s = fake.session() + project = fake.categorization_project() + + op = tc.categorization._apply_feedback_async(s, project) + assert op.type == "SPARK" + assert op.description == "Materialize views to Elastic" + assert op.status == { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark", + } + + +@fake.json +def test_update_results_async(): + s = fake.session() + project = fake.categorization_project() + + op = tc.categorization._update_results_async(s, project) + assert op.type == "SPARK" + assert op.description == "Materialize views to Elastic" + assert op.status == { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark", + } diff --git a/tests/tamr_client/fake_json/categorization/test_categorization/test_apply_feedback_async.json b/tests/tamr_client/fake_json/categorization/test_categorization/test_apply_feedback_async.json new file mode 100644 index 00000000..b05cd6a1 --- /dev/null +++ b/tests/tamr_client/fake_json/categorization/test_categorization/test_apply_feedback_async.json @@ -0,0 +1,33 @@ +[ + { + "request": { + "method": "POST", + "path": "projects/2/categorizations/model:refresh" + }, + "response": { + "status": 200, + "json": { + "id": "1", + "type": "SPARK", + "description": "Materialize views to Elastic", + "status": { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark" + }, + "created": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 modified version" + }, + "relativeId": "operations/1" + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/categorization/test_categorization/test_update_results_async.json b/tests/tamr_client/fake_json/categorization/test_categorization/test_update_results_async.json new file mode 100644 index 00000000..6b3a26b0 --- /dev/null +++ b/tests/tamr_client/fake_json/categorization/test_categorization/test_update_results_async.json @@ -0,0 +1,33 @@ +[ + { + "request": { + "method": "POST", + "path": "projects/2/categorizations:refresh" + }, + "response": { + "status": 200, + "json": { + "id": "1", + "type": "SPARK", + "description": "Materialize views to Elastic", + "status": { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark" + }, + "created": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 modified version" + }, + "relativeId": "operations/1" + } + } + } +] \ No newline at end of file From bb7d6fdcfef38536f0bf07f03eb566de632da45c Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 21 Aug 2020 18:22:08 -0400 Subject: [PATCH 575/632] Add schema mapping workflow functions with docs. Nothing to test. --- docs/beta/schema_mapping.md | 1 + docs/beta/schema_mapping/schema_mapping.rst | 4 +++ tamr_client/schema_mapping/__init__.py | 4 +++ tamr_client/schema_mapping/_schema_mapping.py | 31 +++++++++++++++++++ 4 files changed, 40 insertions(+) create mode 100644 docs/beta/schema_mapping/schema_mapping.rst create mode 100644 tamr_client/schema_mapping/_schema_mapping.py diff --git a/docs/beta/schema_mapping.md b/docs/beta/schema_mapping.md index 663695d6..27f103a5 100644 --- a/docs/beta/schema_mapping.md +++ b/docs/beta/schema_mapping.md @@ -1,3 +1,4 @@ # Schema Mapping + * [Schema Mapping](/beta/schema_mapping/schema_mapping) * [Project](/beta/schema_mapping/project) diff --git a/docs/beta/schema_mapping/schema_mapping.rst b/docs/beta/schema_mapping/schema_mapping.rst new file mode 100644 index 00000000..3ace7dfe --- /dev/null +++ b/docs/beta/schema_mapping/schema_mapping.rst @@ -0,0 +1,4 @@ +Schema Mapping +============== + +.. autofunction:: tamr_client.schema_mapping.update_unified_dataset diff --git a/tamr_client/schema_mapping/__init__.py b/tamr_client/schema_mapping/__init__.py index 6ffac2ab..a32dce65 100644 --- a/tamr_client/schema_mapping/__init__.py +++ b/tamr_client/schema_mapping/__init__.py @@ -3,3 +3,7 @@ See https://docs.tamr.com/new/docs/overall-workflow-schema """ from tamr_client.schema_mapping import project +from tamr_client.schema_mapping._schema_mapping import ( + _update_unified_dataset_async, + update_unified_dataset, +) diff --git a/tamr_client/schema_mapping/_schema_mapping.py b/tamr_client/schema_mapping/_schema_mapping.py new file mode 100644 index 00000000..6400eaa3 --- /dev/null +++ b/tamr_client/schema_mapping/_schema_mapping.py @@ -0,0 +1,31 @@ +""" +Tamr - Schema Mapping +See https://docs.tamr.com/new/docs/overall-workflow-schema + +The terminology used here is consistent with Tamr UI terminology + +Asynchronous versions of each function can be found with the suffix `_async` and may be of +interest to power users +""" +from tamr_client import operation +from tamr_client._types import Operation, SchemaMappingProject, Session +from tamr_client.dataset import unified + + +def update_unified_dataset( + session: Session, project: SchemaMappingProject +) -> Operation: + """Apply changes to the unified dataset and wait for the operation to complete + + Args: + project: Tamr Schema Mapping project + """ + op = _update_unified_dataset_async(session, project) + return operation.wait(session, op) + + +def _update_unified_dataset_async( + session: Session, project: SchemaMappingProject +) -> Operation: + unified_dataset = unified.from_project(session, project) + return unified._apply_changes_async(session, unified_dataset) From 53d4116fadb8a7314f4529a713bd640a08832511 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 21 Aug 2020 18:29:23 -0400 Subject: [PATCH 576/632] Update changelog. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5daf9c5e..21564b48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - [#443](https://github.com/Datatamer/tamr-client/pull/443) Added function to materialize datasets. - [#445](https://github.com/Datatamer/tamr-client/pull/445) Added functions for getting projects and datasets by name via `tc.project.by_name` and `tc.dataset.by_name` - Renamed functions `from_resource_id` to `by_resource_id` in `tc.attribute`, `tc.dataset`, `tc.operation`, and `tc.project` + - [#446](https://github.com/Datatamer/tamr-client/pull/446) Added functions for categorization workflow operations in `tc.categorization` and schema mapping workflow operations in `tc.schema_mapping` **NEW FEATURES** - [#383](https://github.com/Datatamer/tamr-client/issues/383) Now able to create an Operation from Job resource id From d65211b79e3935bb0ca2684d441bce38e78077fd Mon Sep 17 00:00:00 2001 From: skalish Date: Tue, 1 Sep 2020 09:57:09 -0400 Subject: [PATCH 577/632] Move function manual_labels from tc.categorization.project to tc.categorization. --- docs/beta/categorization/categorization.rst | 1 + docs/beta/categorization/project.rst | 3 +-- tamr_client/categorization/__init__.py | 1 + tamr_client/categorization/_categorization.py | 24 +++++++++++++++++-- tamr_client/categorization/project.py | 22 ----------------- .../categorization/test_categorization.py | 8 +++++++ .../categorization/test_project.py | 10 -------- .../test_manual_labels.json | 0 8 files changed, 33 insertions(+), 36 deletions(-) delete mode 100644 tests/tamr_client/categorization/test_project.py rename tests/tamr_client/fake_json/categorization/{test_project => test_categorization}/test_manual_labels.json (100%) diff --git a/docs/beta/categorization/categorization.rst b/docs/beta/categorization/categorization.rst index dfaaec1b..c2e4fe46 100644 --- a/docs/beta/categorization/categorization.rst +++ b/docs/beta/categorization/categorization.rst @@ -4,3 +4,4 @@ Categorization .. autofunction:: tamr_client.categorization.update_unified_dataset .. autofunction:: tamr_client.categorization.apply_feedback .. autofunction:: tamr_client.categorization.update_results +.. autofunction:: tamr_client.categorization.manual_labels \ No newline at end of file diff --git a/docs/beta/categorization/project.rst b/docs/beta/categorization/project.rst index 9b57beef..18a142a4 100644 --- a/docs/beta/categorization/project.rst +++ b/docs/beta/categorization/project.rst @@ -3,5 +3,4 @@ Categorization Project .. autoclass:: tamr_client.CategorizationProject -.. autofunction:: tamr_client.categorization.project.create -.. autofunction:: tamr_client.categorization.project.manual_labels \ No newline at end of file +.. autofunction:: tamr_client.categorization.project.create \ No newline at end of file diff --git a/tamr_client/categorization/__init__.py b/tamr_client/categorization/__init__.py index d24789f9..8d896e34 100644 --- a/tamr_client/categorization/__init__.py +++ b/tamr_client/categorization/__init__.py @@ -8,6 +8,7 @@ _update_results_async, _update_unified_dataset_async, apply_feedback, + manual_labels, update_results, update_unified_dataset, ) diff --git a/tamr_client/categorization/_categorization.py b/tamr_client/categorization/_categorization.py index 8854e75e..a215adff 100644 --- a/tamr_client/categorization/_categorization.py +++ b/tamr_client/categorization/_categorization.py @@ -8,8 +8,28 @@ interest to power users """ from tamr_client import operation -from tamr_client._types import CategorizationProject, Operation, Session -from tamr_client.dataset import unified +from tamr_client._types import CategorizationProject, Dataset, Operation, Session +from tamr_client.dataset import _dataset, unified + + +def manual_labels(session: Session, project: CategorizationProject) -> Dataset: + """Get manual labels from a Categorization project. + + Args: + project: Tamr project containing labels + + Returns: + Dataset containing manual labels + + Raises: + dataset.NotFound: If no dataset could be found at the specified URL + dataset.Ambiguous: If multiple targets match dataset name + """ + unified_dataset = unified.from_project(session=session, project=project) + labels_dataset_name = unified_dataset.name + "_manual_categorizations" + return _dataset.by_name( + session=session, instance=project.url.instance, name=labels_dataset_name + ) def update_unified_dataset( diff --git a/tamr_client/categorization/project.py b/tamr_client/categorization/project.py index 1634d0d3..e2d6eec8 100644 --- a/tamr_client/categorization/project.py +++ b/tamr_client/categorization/project.py @@ -3,14 +3,12 @@ from tamr_client import project from tamr_client._types import ( CategorizationProject, - Dataset, Instance, JsonDict, Project, Session, URL, ) -from tamr_client.dataset import _dataset, unified def _from_json(url: URL, data: JsonDict) -> CategorizationProject: @@ -58,23 +56,3 @@ def create( external_id=external_id, unified_dataset_name=unified_dataset_name, ) - - -def manual_labels(session: Session, project: CategorizationProject) -> Dataset: - """Get manual labels from a Categorization project. - - Args: - project: Tamr project containing labels - - Returns: - Dataset containing manual labels - - Raises: - dataset.NotFound: If no dataset could be found at the specified URL - dataset.Ambiguous: If multiple targets match dataset name - """ - unified_dataset = unified.from_project(session=session, project=project) - labels_dataset_name = unified_dataset.name + "_manual_categorizations" - return _dataset.by_name( - session=session, instance=project.url.instance, name=labels_dataset_name - ) diff --git a/tests/tamr_client/categorization/test_categorization.py b/tests/tamr_client/categorization/test_categorization.py index c6be4990..649393e5 100644 --- a/tests/tamr_client/categorization/test_categorization.py +++ b/tests/tamr_client/categorization/test_categorization.py @@ -2,6 +2,14 @@ from tests.tamr_client import fake +@fake.json +def test_manual_labels(): + s = fake.session() + project = fake.categorization_project() + + tc.categorization.manual_labels(session=s, project=project) + + @fake.json def test_apply_feedback_async(): s = fake.session() diff --git a/tests/tamr_client/categorization/test_project.py b/tests/tamr_client/categorization/test_project.py deleted file mode 100644 index 3c717f18..00000000 --- a/tests/tamr_client/categorization/test_project.py +++ /dev/null @@ -1,10 +0,0 @@ -import tamr_client as tc -from tests.tamr_client import fake - - -@fake.json -def test_manual_labels(): - s = fake.session() - project = fake.categorization_project() - - tc.categorization.project.manual_labels(session=s, project=project) diff --git a/tests/tamr_client/fake_json/categorization/test_project/test_manual_labels.json b/tests/tamr_client/fake_json/categorization/test_categorization/test_manual_labels.json similarity index 100% rename from tests/tamr_client/fake_json/categorization/test_project/test_manual_labels.json rename to tests/tamr_client/fake_json/categorization/test_categorization/test_manual_labels.json From 24eafcd8bb5d143f6a4e1b55a0e1ec38d4017c68 Mon Sep 17 00:00:00 2001 From: skalish Date: Tue, 1 Sep 2020 10:31:34 -0400 Subject: [PATCH 578/632] Remove _update_unified_dataset_async from _mastering, _categorization, and _schema_mapping. --- tamr_client/categorization/__init__.py | 1 - tamr_client/categorization/_categorization.py | 10 ++-------- tamr_client/mastering/__init__.py | 1 - tamr_client/mastering/_mastering.py | 10 ++-------- tamr_client/schema_mapping/__init__.py | 5 +---- tamr_client/schema_mapping/_schema_mapping.py | 10 ++-------- 6 files changed, 7 insertions(+), 30 deletions(-) diff --git a/tamr_client/categorization/__init__.py b/tamr_client/categorization/__init__.py index 8d896e34..3b51e3ed 100644 --- a/tamr_client/categorization/__init__.py +++ b/tamr_client/categorization/__init__.py @@ -6,7 +6,6 @@ from tamr_client.categorization._categorization import ( _apply_feedback_async, _update_results_async, - _update_unified_dataset_async, apply_feedback, manual_labels, update_results, diff --git a/tamr_client/categorization/_categorization.py b/tamr_client/categorization/_categorization.py index a215adff..d1523afc 100644 --- a/tamr_client/categorization/_categorization.py +++ b/tamr_client/categorization/_categorization.py @@ -40,7 +40,8 @@ def update_unified_dataset( Args: project: Tamr Categorization project """ - op = _update_unified_dataset_async(session, project) + unified_dataset = unified.from_project(session, project) + op = unified._apply_changes_async(session, unified_dataset) return operation.wait(session, op) @@ -66,13 +67,6 @@ def update_results(session: Session, project: CategorizationProject) -> Operatio return operation.wait(session, op) -def _update_unified_dataset_async( - session: Session, project: CategorizationProject -) -> Operation: - unified_dataset = unified.from_project(session, project) - return unified._apply_changes_async(session, unified_dataset) - - def _apply_feedback_async( session: Session, project: CategorizationProject ) -> Operation: diff --git a/tamr_client/mastering/__init__.py b/tamr_client/mastering/__init__.py index 9d836375..ac82ef92 100644 --- a/tamr_client/mastering/__init__.py +++ b/tamr_client/mastering/__init__.py @@ -11,7 +11,6 @@ _update_cluster_results_async, _update_high_impact_pairs_async, _update_pair_results_async, - _update_unified_dataset_async, apply_feedback, estimate_pairs, generate_pairs, diff --git a/tamr_client/mastering/_mastering.py b/tamr_client/mastering/_mastering.py index 7eb3cc47..62e10e46 100644 --- a/tamr_client/mastering/_mastering.py +++ b/tamr_client/mastering/_mastering.py @@ -18,7 +18,8 @@ def update_unified_dataset(session: Session, project: MasteringProject) -> Opera Args: project: Tamr Mastering project """ - op = _update_unified_dataset_async(session, project) + unified_dataset = unified.from_project(session, project) + op = unified._apply_changes_async(session, unified_dataset) return operation.wait(session, op) @@ -97,13 +98,6 @@ def publish_clusters(session: Session, project: MasteringProject) -> Operation: return operation.wait(session, op) -def _update_unified_dataset_async( - session: Session, project: MasteringProject -) -> Operation: - unified_dataset = unified.from_project(session, project) - return unified._apply_changes_async(session, unified_dataset) - - def _estimate_pairs_async(session: Session, project: MasteringProject) -> Operation: r = session.post(str(project.url) + "/estimatedPairCounts:refresh") return operation._from_response(project.url.instance, r) diff --git a/tamr_client/schema_mapping/__init__.py b/tamr_client/schema_mapping/__init__.py index a32dce65..eb6fd44e 100644 --- a/tamr_client/schema_mapping/__init__.py +++ b/tamr_client/schema_mapping/__init__.py @@ -3,7 +3,4 @@ See https://docs.tamr.com/new/docs/overall-workflow-schema """ from tamr_client.schema_mapping import project -from tamr_client.schema_mapping._schema_mapping import ( - _update_unified_dataset_async, - update_unified_dataset, -) +from tamr_client.schema_mapping._schema_mapping import update_unified_dataset diff --git a/tamr_client/schema_mapping/_schema_mapping.py b/tamr_client/schema_mapping/_schema_mapping.py index 6400eaa3..66349e4b 100644 --- a/tamr_client/schema_mapping/_schema_mapping.py +++ b/tamr_client/schema_mapping/_schema_mapping.py @@ -20,12 +20,6 @@ def update_unified_dataset( Args: project: Tamr Schema Mapping project """ - op = _update_unified_dataset_async(session, project) - return operation.wait(session, op) - - -def _update_unified_dataset_async( - session: Session, project: SchemaMappingProject -) -> Operation: unified_dataset = unified.from_project(session, project) - return unified._apply_changes_async(session, unified_dataset) + op = unified._apply_changes_async(session, unified_dataset) + return operation.wait(session, op) From e96bbf82f2ab48e8d6d9335c3d93096ba6059b48 Mon Sep 17 00:00:00 2001 From: skalish Date: Tue, 1 Sep 2020 16:46:54 -0400 Subject: [PATCH 579/632] Add dataset deletion functionality. --- docs/beta/dataset/dataset.rst | 1 + tamr_client/dataset/__init__.py | 1 + tamr_client/dataset/_dataset.py | 20 +++++++++++++++ tests/tamr_client/dataset/test_dataset.py | 25 +++++++++++++++++++ .../dataset/test_dataset/test_delete.json | 11 ++++++++ .../test_dataset/test_delete_cascading.json | 11 ++++++++ .../test_delete_dataset_not_found.json | 11 ++++++++ 7 files changed, 80 insertions(+) create mode 100644 tests/tamr_client/fake_json/dataset/test_dataset/test_delete.json create mode 100644 tests/tamr_client/fake_json/dataset/test_dataset/test_delete_cascading.json create mode 100644 tests/tamr_client/fake_json/dataset/test_dataset/test_delete_dataset_not_found.json diff --git a/docs/beta/dataset/dataset.rst b/docs/beta/dataset/dataset.rst index b3fb5010..5ccc640e 100644 --- a/docs/beta/dataset/dataset.rst +++ b/docs/beta/dataset/dataset.rst @@ -7,6 +7,7 @@ Dataset .. autofunction:: tamr_client.dataset.by_name .. autofunction:: tamr_client.dataset.attributes .. autofunction:: tamr_client.dataset.materialize +.. autofunction:: tamr_client.dataset.delete Exceptions ---------- diff --git a/tamr_client/dataset/__init__.py b/tamr_client/dataset/__init__.py index b911d458..b243f43a 100644 --- a/tamr_client/dataset/__init__.py +++ b/tamr_client/dataset/__init__.py @@ -5,6 +5,7 @@ attributes, by_name, by_resource_id, + delete, materialize, NotFound, ) diff --git a/tamr_client/dataset/_dataset.py b/tamr_client/dataset/_dataset.py index 93589de0..58532373 100644 --- a/tamr_client/dataset/_dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -157,3 +157,23 @@ def materialize(session: Session, dataset: Dataset) -> Operation: def _materialize_async(session: Session, dataset: Dataset) -> Operation: r = session.post(str(dataset.url) + ":refresh",) return operation._from_response(dataset.url.instance, r) + + +def delete(session: Session, dataset: Dataset, *, cascade: bool = False): + """Deletes an existing dataset + + Sends a deletion request to the Tamr server + + Args: + dataset: Existing dataset to delete + cascade: Whether to delete all derived datasets as well + + Raises: + dataset.NotFound: If no dataset could be found at the specified URL. + Corresponds to a 404 HTTP error. + requests.HTTPError: If any other HTTP error is encountered. + """ + r = session.delete(str(dataset.url), params={"cascade": cascade},) + if r.status_code == 404: + raise NotFound(str(dataset.url)) + response.successful(r) diff --git a/tests/tamr_client/dataset/test_dataset.py b/tests/tamr_client/dataset/test_dataset.py index eebaab5c..51900f26 100644 --- a/tests/tamr_client/dataset/test_dataset.py +++ b/tests/tamr_client/dataset/test_dataset.py @@ -84,3 +84,28 @@ def test_materialize_async(): "endTime": "", "message": "Job has not yet been submitted to Spark", } + + +@fake.json +def test_delete(): + s = fake.session() + dataset = fake.dataset() + + tc.dataset.delete(s, dataset) + + +@fake.json +def test_delete_cascading(): + s = fake.session() + dataset = fake.dataset() + + tc.dataset.delete(s, dataset, cascade=True) + + +@fake.json +def test_delete_dataset_not_found(): + s = fake.session() + dataset = fake.dataset() + + with pytest.raises(tc.dataset.NotFound): + tc.dataset.delete(s, dataset) diff --git a/tests/tamr_client/fake_json/dataset/test_dataset/test_delete.json b/tests/tamr_client/fake_json/dataset/test_dataset/test_delete.json new file mode 100644 index 00000000..d6de19ba --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataset/test_delete.json @@ -0,0 +1,11 @@ +[ + { + "request": { + "method": "DELETE", + "path": "datasets/1?cascade=false" + }, + "response": { + "status": 204 + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/dataset/test_dataset/test_delete_cascading.json b/tests/tamr_client/fake_json/dataset/test_dataset/test_delete_cascading.json new file mode 100644 index 00000000..f01636e9 --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataset/test_delete_cascading.json @@ -0,0 +1,11 @@ +[ + { + "request": { + "method": "DELETE", + "path": "datasets/1?cascade=true" + }, + "response": { + "status": 204 + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/dataset/test_dataset/test_delete_dataset_not_found.json b/tests/tamr_client/fake_json/dataset/test_dataset/test_delete_dataset_not_found.json new file mode 100644 index 00000000..9a566cfe --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataset/test_delete_dataset_not_found.json @@ -0,0 +1,11 @@ +[ + { + "request": { + "method": "DELETE", + "path": "datasets/1?cascade=false" + }, + "response": { + "status": 404 + } + } +] \ No newline at end of file From 97d5fc28c1d3d3da091abb21e0748953e7d7c7cb Mon Sep 17 00:00:00 2001 From: skalish Date: Tue, 1 Sep 2020 16:57:16 -0400 Subject: [PATCH 580/632] Add functionality to delete all records in a dataset (truncate it). --- docs/beta/dataset/record.rst | 3 ++- tamr_client/dataset/record.py | 10 ++++++++++ tests/tamr_client/dataset/test_record.py | 8 ++++++++ .../dataset/test_record/test_delete_all.json | 11 +++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 tests/tamr_client/fake_json/dataset/test_record/test_delete_all.json diff --git a/docs/beta/dataset/record.rst b/docs/beta/dataset/record.rst index ab49772f..cce6072a 100644 --- a/docs/beta/dataset/record.rst +++ b/docs/beta/dataset/record.rst @@ -7,4 +7,5 @@ Record .. autofunction:: tamr_client.record.upsert .. autofunction:: tamr_client.record.delete .. autofunction:: tamr_client.record._update -.. autofunction:: tamr_client.record.stream \ No newline at end of file +.. autofunction:: tamr_client.record.stream +.. autofunction:: tamr_client.record.delete_all \ No newline at end of file diff --git a/tamr_client/dataset/record.py b/tamr_client/dataset/record.py index f7a0916f..ad791968 100644 --- a/tamr_client/dataset/record.py +++ b/tamr_client/dataset/record.py @@ -149,3 +149,13 @@ def stream(session: Session, dataset: AnyDataset) -> Iterator[JsonDict]: """ with session.get(str(dataset.url) + "/records", stream=True) as r: return response.ndjson(r) + + +def delete_all(session: Session, dataset: AnyDataset): + """Delete all records in this dataset + + Args: + dataset: Dataset from which to delete records + """ + r = session.delete(str(dataset.url) + "/records") + response.successful(r) diff --git a/tests/tamr_client/dataset/test_record.py b/tests/tamr_client/dataset/test_record.py index 4bbc5a14..55c5c9d7 100644 --- a/tests/tamr_client/dataset/test_record.py +++ b/tests/tamr_client/dataset/test_record.py @@ -87,6 +87,14 @@ def test_stream(): assert list(records) == _records_json +@fake.json +def test_delete_all(): + s = fake.session() + dataset = fake.dataset() + + tc.record.delete_all(s, dataset) + + _records_json = [{"primary_key": 1}, {"primary_key": 2}] _response_json = { diff --git a/tests/tamr_client/fake_json/dataset/test_record/test_delete_all.json b/tests/tamr_client/fake_json/dataset/test_record/test_delete_all.json new file mode 100644 index 00000000..17824491 --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_record/test_delete_all.json @@ -0,0 +1,11 @@ +[ + { + "request": { + "method": "DELETE", + "path": "datasets/1/records" + }, + "response": { + "status": 204 + } + } +] \ No newline at end of file From fc365dafaaf9e08598fd21e0a5be837399300b17 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 2 Sep 2020 11:32:17 -0400 Subject: [PATCH 581/632] Add functionality to stream all datasets in an instance, with optional filters. --- docs/beta/dataset/dataset.rst | 1 + tamr_client/dataset/__init__.py | 1 + tamr_client/dataset/_dataset.py | 40 ++++++++++++- tests/tamr_client/dataset/test_dataset.py | 53 +++++++++++++++++ .../dataset/test_dataset/test_get_all.json | 59 +++++++++++++++++++ .../test_dataset/test_get_all_filter.json | 36 +++++++++++ .../test_get_all_filter_list.json | 36 +++++++++++ 7 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 tests/tamr_client/fake_json/dataset/test_dataset/test_get_all.json create mode 100644 tests/tamr_client/fake_json/dataset/test_dataset/test_get_all_filter.json create mode 100644 tests/tamr_client/fake_json/dataset/test_dataset/test_get_all_filter_list.json diff --git a/docs/beta/dataset/dataset.rst b/docs/beta/dataset/dataset.rst index 5ccc640e..c8431a49 100644 --- a/docs/beta/dataset/dataset.rst +++ b/docs/beta/dataset/dataset.rst @@ -8,6 +8,7 @@ Dataset .. autofunction:: tamr_client.dataset.attributes .. autofunction:: tamr_client.dataset.materialize .. autofunction:: tamr_client.dataset.delete +.. autofunction:: tamr_client.dataset.get_all Exceptions ---------- diff --git a/tamr_client/dataset/__init__.py b/tamr_client/dataset/__init__.py index b243f43a..0ea37a2c 100644 --- a/tamr_client/dataset/__init__.py +++ b/tamr_client/dataset/__init__.py @@ -6,6 +6,7 @@ by_name, by_resource_id, delete, + get_all, materialize, NotFound, ) diff --git a/tamr_client/dataset/_dataset.py b/tamr_client/dataset/_dataset.py index 58532373..ee7ea75a 100644 --- a/tamr_client/dataset/_dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -3,7 +3,7 @@ """ from copy import deepcopy from dataclasses import replace -from typing import Tuple +from typing import List, Optional, Tuple, Union from tamr_client import operation, response from tamr_client._types import ( @@ -173,7 +173,43 @@ def delete(session: Session, dataset: Dataset, *, cascade: bool = False): Corresponds to a 404 HTTP error. requests.HTTPError: If any other HTTP error is encountered. """ - r = session.delete(str(dataset.url), params={"cascade": cascade},) + r = session.delete(str(dataset.url), params={"cascade": cascade}) if r.status_code == 404: raise NotFound(str(dataset.url)) response.successful(r) + + +def get_all( + session: Session, + instance: Instance, + *, + filter: Optional[Union[str, List[str]]] = None, +) -> Tuple[Dataset, ...]: + """Get all datasets from an instance + + Args: + instance: Tamr instance from which to get datasets + filter: Filter expression, e.g. "externalId==wobbly" + Multiple expressions can be passed as a list + + Returns: + The datasets retrieved from the instance + + Raises: + requests.HTTPError: If an HTTP error is encountered. + """ + url = URL(instance=instance, path="datasets") + + if filter is not None: + r = session.get(str(url), params={"filter": filter}) + else: + r = session.get(str(url)) + + datasets_json = response.successful(r).json() + + datasets = [] + for dataset_json in datasets_json: + dataset_url = URL(instance=instance, path=dataset_json["relativeId"]) + dataset = _from_json(dataset_url, dataset_json) + datasets.append(dataset) + return tuple(datasets) diff --git a/tests/tamr_client/dataset/test_dataset.py b/tests/tamr_client/dataset/test_dataset.py index 51900f26..754d305b 100644 --- a/tests/tamr_client/dataset/test_dataset.py +++ b/tests/tamr_client/dataset/test_dataset.py @@ -109,3 +109,56 @@ def test_delete_dataset_not_found(): with pytest.raises(tc.dataset.NotFound): tc.dataset.delete(s, dataset) + + +@fake.json +def test_get_all(): + s = fake.session() + instance = fake.instance() + + all_datasets = tc.dataset.get_all(s, instance) + assert len(all_datasets) == 2 + + dataset_1 = all_datasets[0] + assert dataset_1.name == "dataset 1 name" + assert dataset_1.description == "dataset 1 description" + assert dataset_1.key_attribute_names == ("tamr_id",) + + dataset_2 = all_datasets[1] + assert dataset_2.name == "dataset 2 name" + assert dataset_2.description == "dataset 2 description" + assert dataset_2.key_attribute_names == ("tamr_id",) + + +@fake.json +def test_get_all_filter(): + s = fake.session() + instance = fake.instance() + + all_datasets = tc.dataset.get_all( + s, instance, filter="description==dataset 2 description" + ) + assert len(all_datasets) == 1 + + dataset = all_datasets[0] + assert dataset.name == "dataset 2 name" + assert dataset.description == "dataset 2 description" + assert dataset.key_attribute_names == ("tamr_id",) + + +@fake.json +def test_get_all_filter_list(): + s = fake.session() + instance = fake.instance() + + all_datasets = tc.dataset.get_all( + s, + instance, + filter=["description==dataset 2 description", "version==dataset 2 version"], + ) + assert len(all_datasets) == 1 + + dataset = all_datasets[0] + assert dataset.name == "dataset 2 name" + assert dataset.description == "dataset 2 description" + assert dataset.key_attribute_names == ("tamr_id",) diff --git a/tests/tamr_client/fake_json/dataset/test_dataset/test_get_all.json b/tests/tamr_client/fake_json/dataset/test_dataset/test_get_all.json new file mode 100644 index 00000000..fa555908 --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataset/test_get_all.json @@ -0,0 +1,59 @@ +[ + { + "request": { + "method": "GET", + "path": "datasets" + }, + "response": { + "status": 200, + "json": [ + { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "dataset 1 name", + "description": "dataset 1 description", + "version": "dataset 1 version", + "keyAttributeNames": [ + "tamr_id" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] + }, + { + "id": "unify://unified-data/v1/datasets/2", + "externalId": "number 2", + "name": "dataset 2 name", + "description": "dataset 2 description", + "version": "dataset 2 version", + "keyAttributeNames": [ + "tamr_id" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 2 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 2 modified version" + }, + "relativeId": "datasets/2", + "upstreamDatasetIds": [] + } + ] + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/dataset/test_dataset/test_get_all_filter.json b/tests/tamr_client/fake_json/dataset/test_dataset/test_get_all_filter.json new file mode 100644 index 00000000..b99f4a4b --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataset/test_get_all_filter.json @@ -0,0 +1,36 @@ +[ + { + "request": { + "method": "GET", + "path": "datasets?filter=description==dataset%202%20description" + }, + "response": { + "status": 200, + "json": [ + { + "id": "unify://unified-data/v1/datasets/2", + "externalId": "number 2", + "name": "dataset 2 name", + "description": "dataset 2 description", + "version": "dataset 2 version", + "keyAttributeNames": [ + "tamr_id" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 2 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 2 modified version" + }, + "relativeId": "datasets/2", + "upstreamDatasetIds": [] + } + ] + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/dataset/test_dataset/test_get_all_filter_list.json b/tests/tamr_client/fake_json/dataset/test_dataset/test_get_all_filter_list.json new file mode 100644 index 00000000..c935936b --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataset/test_get_all_filter_list.json @@ -0,0 +1,36 @@ +[ + { + "request": { + "method": "GET", + "path": "datasets?filter=description==dataset%202%20description&?filter=version==dataset%202%20version" + }, + "response": { + "status": 200, + "json": [ + { + "id": "unify://unified-data/v1/datasets/2", + "externalId": "number 2", + "name": "dataset 2 name", + "description": "dataset 2 description", + "version": "dataset 2 version", + "keyAttributeNames": [ + "tamr_id" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 2 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 2 modified version" + }, + "relativeId": "datasets/2", + "upstreamDatasetIds": [] + } + ] + } + } +] \ No newline at end of file From f655fa057608c79eb979d053a1b5a338470337c0 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 2 Sep 2020 11:55:40 -0400 Subject: [PATCH 582/632] Add functionality to stream all projects in an instance, with optional filters. --- docs/beta/project.rst | 1 + tamr_client/project.py | 38 +++++++++++++- .../fake_json/test_project/test_get_all.json | 51 +++++++++++++++++++ .../test_project/test_get_all_filter.json | 32 ++++++++++++ .../test_get_all_filter_list.json | 32 ++++++++++++ tests/tamr_client/test_project.py | 51 +++++++++++++++++++ 6 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 tests/tamr_client/fake_json/test_project/test_get_all.json create mode 100644 tests/tamr_client/fake_json/test_project/test_get_all_filter.json create mode 100644 tests/tamr_client/fake_json/test_project/test_get_all_filter_list.json diff --git a/docs/beta/project.rst b/docs/beta/project.rst index 6c2d4de8..ad2e9b8a 100644 --- a/docs/beta/project.rst +++ b/docs/beta/project.rst @@ -3,6 +3,7 @@ Project .. autofunction:: tamr_client.project.by_resource_id .. autofunction:: tamr_client.project.by_name +.. autofunction:: tamr_client.project.get_all Exceptions ---------- diff --git a/tamr_client/project.py b/tamr_client/project.py index ba23b8c0..1dda24d2 100644 --- a/tamr_client/project.py +++ b/tamr_client/project.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import List, Optional, Tuple, Union from tamr_client import response from tamr_client._types import Instance, JsonDict, Project, Session, URL @@ -157,3 +157,39 @@ def _create( project_url = URL(instance=instance, path=str(project_path)) return _by_url(session=session, url=project_url) + + +def get_all( + session: Session, + instance: Instance, + *, + filter: Optional[Union[str, List[str]]] = None, +) -> Tuple[Project, ...]: + """Get all projects from an instance + + Args: + instance: Tamr instance from which to get projects + filter: Filter expression, e.g. "externalId==wobbly" + Multiple expressions can be passed as a list + + Returns: + The projects retrieved from the instance + + Raises: + requests.HTTPError: If an HTTP error is encountered. + """ + url = URL(instance=instance, path="projects") + + if filter is not None: + r = session.get(str(url), params={"filter": filter}) + else: + r = session.get(str(url)) + + projects_json = response.successful(r).json() + + projects = [] + for project_json in projects_json: + project_url = URL(instance=instance, path=project_json["relativeId"]) + project = _from_json(project_url, project_json) + projects.append(project) + return tuple(projects) diff --git a/tests/tamr_client/fake_json/test_project/test_get_all.json b/tests/tamr_client/fake_json/test_project/test_get_all.json new file mode 100644 index 00000000..fa088939 --- /dev/null +++ b/tests/tamr_client/fake_json/test_project/test_get_all.json @@ -0,0 +1,51 @@ +[ + { + "request": { + "method": "GET", + "path": "projects" + }, + "response": { + "status": 200, + "json": [ + { + "id": "unify://unified-data/v1/projects/1", + "name": "project 1", + "description": "Mastering Project", + "type": "DEDUP", + "unifiedDatasetName": "project_1_unified_dataset", + "created": { + "username": "admin", + "time": "2020-04-03T14:14:18.752Z", + "version": "18" + }, + "lastModified": { + "username": "admin", + "time": "2020-04-03T14:14:20.115Z", + "version": "19" + }, + "relativeId": "projects/1", + "externalId": "58bdbe72-3c08-427d-97bd-45b16d92c79c" + }, + { + "id": "unify://unified-data/v1/projects/2", + "name": "project 2", + "description": "Categorization Project", + "type": "CATEGORIZATION", + "unifiedDatasetName": "project_2_unified_dataset", + "created": { + "username": "admin", + "time": "2020-08-04T14:54:11.767Z", + "version": "20" + }, + "lastModified": { + "username": "admin", + "time": "2020-08-04T14:54:11.767Z", + "version": "21" + }, + "relativeId": "projects/2", + "externalId": "98f9e4ee-1a35-4242-917d-1163363d5411" + } + ] + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/test_project/test_get_all_filter.json b/tests/tamr_client/fake_json/test_project/test_get_all_filter.json new file mode 100644 index 00000000..78fb7d2c --- /dev/null +++ b/tests/tamr_client/fake_json/test_project/test_get_all_filter.json @@ -0,0 +1,32 @@ +[ + { + "request": { + "method": "GET", + "path": "projects?filter=description==Categorization%20Project" + }, + "response": { + "status": 200, + "json": [ + { + "id": "unify://unified-data/v1/projects/2", + "name": "project 2", + "description": "Categorization Project", + "type": "CATEGORIZATION", + "unifiedDatasetName": "project_2_unified_dataset", + "created": { + "username": "admin", + "time": "2020-08-04T14:54:11.767Z", + "version": "20" + }, + "lastModified": { + "username": "admin", + "time": "2020-08-04T14:54:11.767Z", + "version": "21" + }, + "relativeId": "projects/2", + "externalId": "98f9e4ee-1a35-4242-917d-1163363d5411" + } + ] + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/test_project/test_get_all_filter_list.json b/tests/tamr_client/fake_json/test_project/test_get_all_filter_list.json new file mode 100644 index 00000000..aa83afa8 --- /dev/null +++ b/tests/tamr_client/fake_json/test_project/test_get_all_filter_list.json @@ -0,0 +1,32 @@ +[ + { + "request": { + "method": "GET", + "path": "projects?filter=description==Categorization%20Project&?filter=name==project%202" + }, + "response": { + "status": 200, + "json": [ + { + "id": "unify://unified-data/v1/projects/2", + "name": "project 2", + "description": "Categorization Project", + "type": "CATEGORIZATION", + "unifiedDatasetName": "project_2_unified_dataset", + "created": { + "username": "admin", + "time": "2020-08-04T14:54:11.767Z", + "version": "20" + }, + "lastModified": { + "username": "admin", + "time": "2020-08-04T14:54:11.767Z", + "version": "21" + }, + "relativeId": "projects/2", + "externalId": "98f9e4ee-1a35-4242-917d-1163363d5411" + } + ] + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/test_project.py b/tests/tamr_client/test_project.py index 1a62b847..f67d595d 100644 --- a/tests/tamr_client/test_project.py +++ b/tests/tamr_client/test_project.py @@ -61,3 +61,54 @@ def test_by_name_project_ambiguous(): with pytest.raises(tc.project.Ambiguous): tc.project.by_name(s, instance, "ambiguous project") + + +@fake.json +def test_get_all(): + s = fake.session() + instance = fake.instance() + + all_projects = tc.project.get_all(s, instance) + assert len(all_projects) == 2 + + project_1 = all_projects[0] + assert isinstance(project_1, tc.MasteringProject) + assert project_1.name == "project 1" + assert project_1.description == "Mastering Project" + + project_2 = all_projects[1] + assert isinstance(project_2, tc.CategorizationProject) + assert project_2.name == "project 2" + assert project_2.description == "Categorization Project" + + +@fake.json +def test_get_all_filter(): + s = fake.session() + instance = fake.instance() + + all_projects = tc.project.get_all( + s, instance, filter="description==Categorization Project" + ) + assert len(all_projects) == 1 + + project = all_projects[0] + assert isinstance(project, tc.CategorizationProject) + assert project.name == "project 2" + assert project.description == "Categorization Project" + + +@fake.json +def test_get_all_filter_list(): + s = fake.session() + instance = fake.instance() + + all_projects = tc.project.get_all( + s, instance, filter=["description==Categorization Project", "name==project 2"] + ) + assert len(all_projects) == 1 + + project = all_projects[0] + assert isinstance(project, tc.CategorizationProject) + assert project.name == "project 2" + assert project.description == "Categorization Project" From 867c287e422fbb7d7a9a896cd499880fcf9ca83b Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 2 Sep 2020 16:56:46 -0400 Subject: [PATCH 583/632] Add dataset creation functionality. --- docs/beta/dataset/dataset.rst | 1 + tamr_client/dataset/__init__.py | 1 + tamr_client/dataset/_dataset.py | 41 ++++++++++ tests/tamr_client/dataset/test_dataset.py | 17 +++++ .../dataset/test_dataset/test_create.json | 74 +++++++++++++++++++ 5 files changed, 134 insertions(+) create mode 100644 tests/tamr_client/fake_json/dataset/test_dataset/test_create.json diff --git a/docs/beta/dataset/dataset.rst b/docs/beta/dataset/dataset.rst index c8431a49..bb49cd3e 100644 --- a/docs/beta/dataset/dataset.rst +++ b/docs/beta/dataset/dataset.rst @@ -9,6 +9,7 @@ Dataset .. autofunction:: tamr_client.dataset.materialize .. autofunction:: tamr_client.dataset.delete .. autofunction:: tamr_client.dataset.get_all +.. autofunction:: tamr_client.dataset.create Exceptions ---------- diff --git a/tamr_client/dataset/__init__.py b/tamr_client/dataset/__init__.py index 0ea37a2c..4f085609 100644 --- a/tamr_client/dataset/__init__.py +++ b/tamr_client/dataset/__init__.py @@ -5,6 +5,7 @@ attributes, by_name, by_resource_id, + create, delete, get_all, materialize, diff --git a/tamr_client/dataset/_dataset.py b/tamr_client/dataset/_dataset.py index ee7ea75a..fe69f4f7 100644 --- a/tamr_client/dataset/_dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -213,3 +213,44 @@ def get_all( dataset = _from_json(dataset_url, dataset_json) datasets.append(dataset) return tuple(datasets) + + +def create( + session: Session, + instance: Instance, + *, + name: str, + key_attribute_names: Tuple[str, ...], + description: Optional[str] = None, + external_id: Optional[str] = None, +) -> Dataset: + """Create a dataset in Tamr. + + Args: + instance: Tamr instance + name: Dataset name + key_attribute_names: Dataset primary key attribute names + description: Dataset description + external_id: External ID of the dataset + + Returns: + Dataset created in Tamr + + Raises: + requests.HTTPError: If any other HTTP error is encountered. + """ + data = { + "name": name, + "keyAttributeNames": key_attribute_names, + "description": description, + "externalId": external_id, + } + + dataset_url = URL(instance=instance, path="datasets") + r = session.post(url=str(dataset_url), json=data) + + data = response.successful(r).json() + dataset_path = data["relativeId"] + dataset_url = URL(instance=instance, path=str(dataset_path)) + + return _by_url(session=session, url=dataset_url) diff --git a/tests/tamr_client/dataset/test_dataset.py b/tests/tamr_client/dataset/test_dataset.py index 754d305b..c7d9bf7e 100644 --- a/tests/tamr_client/dataset/test_dataset.py +++ b/tests/tamr_client/dataset/test_dataset.py @@ -162,3 +162,20 @@ def test_get_all_filter_list(): assert dataset.name == "dataset 2 name" assert dataset.description == "dataset 2 description" assert dataset.key_attribute_names == ("tamr_id",) + + +@fake.json +def test_create(): + s = fake.session() + instance = fake.instance() + + dataset = tc.dataset.create( + s, + instance, + name="new dataset", + key_attribute_names=("primary_key",), + description="a new dataset", + ) + assert dataset.name == "new dataset" + assert dataset.description == "a new dataset" + assert dataset.key_attribute_names == ("primary_key",) diff --git a/tests/tamr_client/fake_json/dataset/test_dataset/test_create.json b/tests/tamr_client/fake_json/dataset/test_dataset/test_create.json new file mode 100644 index 00000000..0db82113 --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataset/test_create.json @@ -0,0 +1,74 @@ +[ + { + "request": { + "method": "POST", + "path": "datasets", + "json": { + "name": "new dataset", + "keyAttributeNames": [ + "primary_key" + ], + "description": "a new dataset", + "externalId": null + } + }, + "response": { + "status": 201, + "json": { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "new dataset", + "description": "a new dataset", + "version": "dataset version", + "keyAttributeNames": [ + "primary_key" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] + } + } + }, + { + "request": { + "method": "GET", + "path": "datasets/1" + }, + "response": { + "status": 200, + "json": { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "new dataset", + "description": "a new dataset", + "version": "dataset version", + "keyAttributeNames": [ + "primary_key" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] + } + } + } +] \ No newline at end of file From 5f21dfbce633af58683d4d6dc13f5657e0108258 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 2 Sep 2020 17:49:05 -0400 Subject: [PATCH 584/632] Update changelog. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21564b48..09b26c2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ - [#445](https://github.com/Datatamer/tamr-client/pull/445) Added functions for getting projects and datasets by name via `tc.project.by_name` and `tc.dataset.by_name` - Renamed functions `from_resource_id` to `by_resource_id` in `tc.attribute`, `tc.dataset`, `tc.operation`, and `tc.project` - [#446](https://github.com/Datatamer/tamr-client/pull/446) Added functions for categorization workflow operations in `tc.categorization` and schema mapping workflow operations in `tc.schema_mapping` + - [#452](https://github.com/Datatamer/tamr-client/pull/452) Added functions for creating and deleting a dataset via `tc.dataset.create` and `tc.dataset.delete` + - Added function for deleting all records in a dataset via `tc.record.delete_all` + - Added functions for getting all datasets and projects in a Tamr instance via `get_all` functions in `tc.dataset` and `tc.project` **NEW FEATURES** - [#383](https://github.com/Datatamer/tamr-client/issues/383) Now able to create an Operation from Job resource id From 06252430e7456ad37c83efafc456613f8342a80c Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 3 Sep 2020 09:44:25 -0400 Subject: [PATCH 585/632] Add dataset.AlreadyExists error handling and fix exception references in docstrings. --- docs/beta/dataset/dataset.rst | 3 +++ tamr_client/attribute/_attribute.py | 4 ++-- tamr_client/categorization/project.py | 2 +- tamr_client/dataset/__init__.py | 1 + tamr_client/dataset/_dataset.py | 10 +++++++++ tamr_client/mastering/project.py | 2 +- tamr_client/operation.py | 2 +- tamr_client/project.py | 4 ++-- tamr_client/schema_mapping/project.py | 2 +- tests/tamr_client/dataset/test_dataset.py | 15 +++++++++++++ .../test_create_dataset_already_exists.json | 22 +++++++++++++++++++ 11 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 tests/tamr_client/fake_json/dataset/test_dataset/test_create_dataset_already_exists.json diff --git a/docs/beta/dataset/dataset.rst b/docs/beta/dataset/dataset.rst index bb49cd3e..4a932a16 100644 --- a/docs/beta/dataset/dataset.rst +++ b/docs/beta/dataset/dataset.rst @@ -19,3 +19,6 @@ Exceptions .. autoclass:: tamr_client.dataset.Ambiguous :no-inherited-members: + +.. autoclass:: tamr_client.dataset.AlreadyExists + :no-inherited-members: diff --git a/tamr_client/attribute/_attribute.py b/tamr_client/attribute/_attribute.py index 62b7df7b..20d37b62 100644 --- a/tamr_client/attribute/_attribute.py +++ b/tamr_client/attribute/_attribute.py @@ -149,8 +149,8 @@ def create( The newly created attribute Raises: - ReservedName: If attribute name is reserved. - AlreadyExists: If an attribute already exists at the specified URL. + attribute.ReservedName: If attribute name is reserved. + attribute.AlreadyExists: If an attribute already exists at the specified URL. Corresponds to a 409 HTTP error. requests.HTTPError: If any other HTTP error is encountered. """ diff --git a/tamr_client/categorization/project.py b/tamr_client/categorization/project.py index e2d6eec8..2ceaaa13 100644 --- a/tamr_client/categorization/project.py +++ b/tamr_client/categorization/project.py @@ -44,7 +44,7 @@ def create( Project created in Tamr Raises: - attribute.AlreadyExists: If a project with these specifications already exists + project.AlreadyExists: If a project with these specifications already exists requests.HTTPError: If any other HTTP error is encountered """ return project._create( diff --git a/tamr_client/dataset/__init__.py b/tamr_client/dataset/__init__.py index 4f085609..4f5f529f 100644 --- a/tamr_client/dataset/__init__.py +++ b/tamr_client/dataset/__init__.py @@ -1,6 +1,7 @@ from tamr_client.dataset import dataframe, record, unified from tamr_client.dataset._dataset import ( _materialize_async, + AlreadyExists, Ambiguous, attributes, by_name, diff --git a/tamr_client/dataset/_dataset.py b/tamr_client/dataset/_dataset.py index fe69f4f7..9e8106be 100644 --- a/tamr_client/dataset/_dataset.py +++ b/tamr_client/dataset/_dataset.py @@ -33,6 +33,12 @@ class Ambiguous(TamrClientException): pass +class AlreadyExists(TamrClientException): + """Raised when a dataset with these specifications already exists.""" + + pass + + def by_resource_id(session: Session, instance: Instance, id: str) -> Dataset: """Get dataset by resource ID @@ -237,6 +243,7 @@ def create( Dataset created in Tamr Raises: + dataset.AlreadyExists: If a dataset with these specifications already exists. requests.HTTPError: If any other HTTP error is encountered. """ data = { @@ -249,6 +256,9 @@ def create( dataset_url = URL(instance=instance, path="datasets") r = session.post(url=str(dataset_url), json=data) + if r.status_code == 400 and "already exists" in r.json()["message"]: + raise AlreadyExists(r.json()["message"]) + data = response.successful(r).json() dataset_path = data["relativeId"] dataset_url = URL(instance=instance, path=str(dataset_path)) diff --git a/tamr_client/mastering/project.py b/tamr_client/mastering/project.py index 933624d7..73f6d970 100644 --- a/tamr_client/mastering/project.py +++ b/tamr_client/mastering/project.py @@ -42,7 +42,7 @@ def create( Project created in Tamr Raises: - AlreadyExists: If a project with these specifications already exists. + project.AlreadyExists: If a project with these specifications already exists. requests.HTTPError: If any other HTTP error is encountered. """ return project._create( diff --git a/tamr_client/operation.py b/tamr_client/operation.py index 01b4c0c3..fa7ff3b3 100644 --- a/tamr_client/operation.py +++ b/tamr_client/operation.py @@ -128,7 +128,7 @@ def _by_url(session: Session, url: URL) -> Operation: url: Operation URL Raises: - OperationNotFound: If no operation could be found at the specified URL. + operation.NotFound: If no operation could be found at the specified URL. Corresponds to a 404 HTTP error. requests.HTTPError: If any other HTTP error is encountered. """ diff --git a/tamr_client/project.py b/tamr_client/project.py index 1dda24d2..3535d027 100644 --- a/tamr_client/project.py +++ b/tamr_client/project.py @@ -82,7 +82,7 @@ def _by_url(session: Session, url: URL) -> Project: url: Project URL Raises: - NotFound: If no project could be found at the specified URL. + project.NotFound: If no project could be found at the specified URL. Corresponds to a 404 HTTP error. requests.HTTPError: If any other HTTP error is encountered. """ @@ -133,7 +133,7 @@ def _create( Project created in Tamr Raises: - AlreadyExists: If a project with these specifications already exists. + project.AlreadyExists: If a project with these specifications already exists. requests.HTTPError: If any other HTTP error is encountered. """ if not unified_dataset_name: diff --git a/tamr_client/schema_mapping/project.py b/tamr_client/schema_mapping/project.py index 6cc77fca..d02b58c0 100644 --- a/tamr_client/schema_mapping/project.py +++ b/tamr_client/schema_mapping/project.py @@ -44,7 +44,7 @@ def create( Project created in Tamr Raises: - AlreadyExists: If a project with these specifications already exists. + project.AlreadyExists: If a project with these specifications already exists. requests.HTTPError: If any other HTTP error is encountered. """ return project._create( diff --git a/tests/tamr_client/dataset/test_dataset.py b/tests/tamr_client/dataset/test_dataset.py index c7d9bf7e..02936d45 100644 --- a/tests/tamr_client/dataset/test_dataset.py +++ b/tests/tamr_client/dataset/test_dataset.py @@ -179,3 +179,18 @@ def test_create(): assert dataset.name == "new dataset" assert dataset.description == "a new dataset" assert dataset.key_attribute_names == ("primary_key",) + + +@fake.json +def test_create_dataset_already_exists(): + s = fake.session() + instance = fake.instance() + + with pytest.raises(tc.dataset.AlreadyExists): + tc.dataset.create( + s, + instance, + name="new dataset", + key_attribute_names=("primary_key",), + description="a new dataset", + ) diff --git a/tests/tamr_client/fake_json/dataset/test_dataset/test_create_dataset_already_exists.json b/tests/tamr_client/fake_json/dataset/test_dataset/test_create_dataset_already_exists.json new file mode 100644 index 00000000..c44b9c6a --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataset/test_create_dataset_already_exists.json @@ -0,0 +1,22 @@ +[ + { + "request": { + "method": "POST", + "path": "datasets", + "json": { + "name": "new dataset", + "keyAttributeNames": [ + "primary_key" + ], + "description": "a new dataset", + "externalId": null + } + }, + "response": { + "status": 400, + "json": { + "message": "Dataset \"new dataset\" already exists" + } + } + } +] \ No newline at end of file From fd9d966d9e3ace3a2855a1bf3b0a396ded0291c6 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 3 Sep 2020 11:49:38 -0400 Subject: [PATCH 586/632] Complete test coverage of _attribute.create --- tests/tamr_client/attribute/test_attribute.py | 2 ++ .../fake_json/attribute/test_attribute/test_create.json | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/tamr_client/attribute/test_attribute.py b/tests/tamr_client/attribute/test_attribute.py index dfdbbf9b..810d9521 100644 --- a/tests/tamr_client/attribute/test_attribute.py +++ b/tests/tamr_client/attribute/test_attribute.py @@ -49,12 +49,14 @@ def test_create(): name="attr", is_nullable=False, type=tc.attribute.type.Record(attributes=attrs), + description="an attribute", ) assert attr.name == "attr" assert not attr.is_nullable assert isinstance(attr.type, tc.attribute.type.Record) assert attr.type.attributes == attrs + assert attr.description == "an attribute" @fake.json diff --git a/tests/tamr_client/fake_json/attribute/test_attribute/test_create.json b/tests/tamr_client/fake_json/attribute/test_attribute/test_create.json index 828e7196..4b7f5e25 100644 --- a/tests/tamr_client/fake_json/attribute/test_attribute/test_create.json +++ b/tests/tamr_client/fake_json/attribute/test_attribute/test_create.json @@ -50,7 +50,8 @@ } } ] - } + }, + "description": "an attribute" } }, "response": { @@ -102,7 +103,8 @@ } } ] - } + }, + "description": "an attribute" } } } From 498de757a7807aa9bb78e59b15e05ecb6b55f733 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 3 Sep 2020 12:00:24 -0400 Subject: [PATCH 587/632] Remove description from sub-attributes. --- tamr_client/_types/attribute.py | 2 -- tamr_client/attribute/sub.py | 2 -- tests/tamr_client/attribute/test_type.py | 3 --- 3 files changed, 7 deletions(-) diff --git a/tamr_client/_types/attribute.py b/tamr_client/_types/attribute.py index f88391f2..c75c5450 100644 --- a/tamr_client/_types/attribute.py +++ b/tamr_client/_types/attribute.py @@ -35,7 +35,6 @@ class SubAttribute: Args: name: Name of sub-attribute - description: Description of sub-attribute type: See https://docs.tamr.com/reference#attribute-types is_nullable: If this sub-attribute can be null """ @@ -43,7 +42,6 @@ class SubAttribute: name: str type: "AttributeType" is_nullable: bool - description: Optional[str] = None # attribute types diff --git a/tamr_client/attribute/sub.py b/tamr_client/attribute/sub.py index 63b91b5e..0e63c5c3 100644 --- a/tamr_client/attribute/sub.py +++ b/tamr_client/attribute/sub.py @@ -34,6 +34,4 @@ def to_json(subattr: SubAttribute) -> JsonDict: "type": attribute_type.to_json(subattr.type), "isNullable": subattr.is_nullable, } - if subattr.description is not None: - d["description"] = subattr.description return d diff --git a/tests/tamr_client/attribute/test_type.py b/tests/tamr_client/attribute/test_type.py index 3b98bc57..3b082e91 100644 --- a/tests/tamr_client/attribute/test_type.py +++ b/tests/tamr_client/attribute/test_type.py @@ -13,14 +13,12 @@ def test_from_json(): assert subattr.name == "point" assert subattr.type == tc.attribute.type.Array(tc.attribute.type.DOUBLE) assert subattr.is_nullable - assert subattr.description is None elif i == 1: assert subattr.name == "lineString" assert subattr.type == tc.attribute.type.Array( tc.attribute.type.Array(tc.attribute.type.DOUBLE) ) assert subattr.is_nullable - assert subattr.description is None elif i == 2: assert subattr.name == "polygon" assert subattr.type == tc.attribute.type.Array( @@ -29,7 +27,6 @@ def test_from_json(): ) ) assert subattr.is_nullable - assert subattr.description is None def test_json(): From 872b8383e2e3a21f216a66eda9feaf545f86e92a Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 3 Sep 2020 13:13:24 -0400 Subject: [PATCH 588/632] Increase test coverage of attribute.type --- tests/tamr_client/attribute/test_type.py | 37 ++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/tamr_client/attribute/test_type.py b/tests/tamr_client/attribute/test_type.py index 3b082e91..49e6b967 100644 --- a/tests/tamr_client/attribute/test_type.py +++ b/tests/tamr_client/attribute/test_type.py @@ -1,3 +1,5 @@ +import pytest + import tamr_client as tc from tests.tamr_client import utils @@ -29,6 +31,41 @@ def test_from_json(): assert subattr.is_nullable +def test_from_json_missing_base_type(): + type_json: tc._types.JsonDict = {"attributes": []} + + with pytest.raises(ValueError): + tc.attribute.type.from_json(type_json) + + +def test_from_json_unrecognized_base_type(): + type_json: tc._types.JsonDict = {"baseType": "NOT_A_TYPE", "attributes": []} + + with pytest.raises(ValueError): + tc.attribute.type.from_json(type_json) + + +def test_from_json_array_missing_inner_type(): + type_json: tc._types.JsonDict = {"baseType": "ARRAY"} + + with pytest.raises(ValueError): + tc.attribute.type.from_json(type_json) + + +def test_from_json_map_missing_inner_type(): + type_json: tc._types.JsonDict = {"baseType": "MAP"} + + with pytest.raises(ValueError): + tc.attribute.type.from_json(type_json) + + +def test_from_json_record_missing_attributes(): + type_json: tc._types.JsonDict = {"baseType": "RECORD"} + + with pytest.raises(ValueError): + tc.attribute.type.from_json(type_json) + + def test_json(): attrs_json = utils.load_json("attributes.json") for attr_json in attrs_json: From 6f5751c6f6a2bd0ff79839fa83d6322802cdd924 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 3 Sep 2020 15:27:10 -0400 Subject: [PATCH 589/632] Add fake.json mock server tests for project creation. --- .../test_categorization_project.py | 18 +++++ .../test_create.json | 65 +++++++++++++++++++ .../test_mastering_project/test_create.json | 65 +++++++++++++++++++ .../test_create.json | 65 +++++++++++++++++++ .../test_create_project_already_exists.json | 21 ++++++ .../mastering/test_mastering_project.py | 15 +++++ tests/tamr_client/schema_mapping/__init__.py | 0 .../test_schema_mapping_project.py | 18 +++++ tests/tamr_client/test_project.py | 23 +++++++ 9 files changed, 290 insertions(+) create mode 100644 tests/tamr_client/categorization/test_categorization_project.py create mode 100644 tests/tamr_client/fake_json/categorization/test_categorization_project/test_create.json create mode 100644 tests/tamr_client/fake_json/mastering/test_mastering_project/test_create.json create mode 100644 tests/tamr_client/fake_json/schema_mapping/test_schema_mapping_project/test_create.json create mode 100644 tests/tamr_client/fake_json/test_project/test_create_project_already_exists.json create mode 100644 tests/tamr_client/mastering/test_mastering_project.py create mode 100644 tests/tamr_client/schema_mapping/__init__.py create mode 100644 tests/tamr_client/schema_mapping/test_schema_mapping_project.py diff --git a/tests/tamr_client/categorization/test_categorization_project.py b/tests/tamr_client/categorization/test_categorization_project.py new file mode 100644 index 00000000..f60935c1 --- /dev/null +++ b/tests/tamr_client/categorization/test_categorization_project.py @@ -0,0 +1,18 @@ +import tamr_client as tc +from tests.tamr_client import fake + + +@fake.json +def test_create(): + s = fake.session() + instance = fake.instance() + + project = tc.categorization.project.create( + s, + instance, + name="New Categorization Project", + description="A Categorization Project", + ) + assert isinstance(project, tc.CategorizationProject) + assert project.name == "New Categorization Project" + assert project.description == "A Categorization Project" diff --git a/tests/tamr_client/fake_json/categorization/test_categorization_project/test_create.json b/tests/tamr_client/fake_json/categorization/test_categorization_project/test_create.json new file mode 100644 index 00000000..249426ae --- /dev/null +++ b/tests/tamr_client/fake_json/categorization/test_categorization_project/test_create.json @@ -0,0 +1,65 @@ +[ + { + "request": { + "method": "POST", + "path": "projects", + "json": { + "name": "New Categorization Project", + "type": "CATEGORIZATION", + "unifiedDatasetName": "New Categorization Project_unified_dataset", + "description": "A Categorization Project", + "externalId": null + } + }, + "response": { + "status": 200, + "json": { + "id": "unify://unified-data/v1/projects/2", + "name": "New Categorization Project", + "description": "A Categorization Project", + "type": "CATEGORIZATION", + "unifiedDatasetName": "New Categorization Project_unified_dataset", + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "modified version" + }, + "relativeId": "projects/2", + "externalId": "b129f3b1-82f5-4e30-90a3-e562ca977992" + } + } + }, + { + "request": { + "method": "GET", + "path": "projects/2" + }, + "response": { + "status": 200, + "json": { + "id": "unify://unified-data/v1/projects/2", + "name": "New Categorization Project", + "description": "A Categorization Project", + "type": "CATEGORIZATION", + "unifiedDatasetName": "New Categorization Project_unified_dataset", + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "modified version" + }, + "relativeId": "projects/2", + "externalId": "b129f3b1-82f5-4e30-90a3-e562ca977992" + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/mastering/test_mastering_project/test_create.json b/tests/tamr_client/fake_json/mastering/test_mastering_project/test_create.json new file mode 100644 index 00000000..c47166ec --- /dev/null +++ b/tests/tamr_client/fake_json/mastering/test_mastering_project/test_create.json @@ -0,0 +1,65 @@ +[ + { + "request": { + "method": "POST", + "path": "projects", + "json": { + "name": "New Mastering Project", + "type": "DEDUP", + "unifiedDatasetName": "New Mastering Project_unified_dataset", + "description": "A Mastering Project", + "externalId": null + } + }, + "response": { + "status": 200, + "json": { + "id": "unify://unified-data/v1/projects/1", + "name": "New Mastering Project", + "description": "A Mastering Project", + "type": "DEDUP", + "unifiedDatasetName": "New Mastering Project_unified_dataset", + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "modified version" + }, + "relativeId": "projects/1", + "externalId": "b129f3b1-82f5-4e30-90a3-e562ca977992" + } + } + }, + { + "request": { + "method": "GET", + "path": "projects/1" + }, + "response": { + "status": 200, + "json": { + "id": "unify://unified-data/v1/projects/1", + "name": "New Mastering Project", + "description": "A Mastering Project", + "type": "DEDUP", + "unifiedDatasetName": "New Mastering Project_unified_dataset", + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "modified version" + }, + "relativeId": "projects/1", + "externalId": "b129f3b1-82f5-4e30-90a3-e562ca977992" + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/schema_mapping/test_schema_mapping_project/test_create.json b/tests/tamr_client/fake_json/schema_mapping/test_schema_mapping_project/test_create.json new file mode 100644 index 00000000..e431e9c9 --- /dev/null +++ b/tests/tamr_client/fake_json/schema_mapping/test_schema_mapping_project/test_create.json @@ -0,0 +1,65 @@ +[ + { + "request": { + "method": "POST", + "path": "projects", + "json": { + "name": "New Schema Mapping Project", + "type": "SCHEMA_MAPPING_RECOMMENDATIONS", + "unifiedDatasetName": "New Schema Mapping Project_unified_dataset", + "description": "A Schema Mapping Project", + "externalId": null + } + }, + "response": { + "status": 200, + "json": { + "id": "unify://unified-data/v1/projects/3", + "name": "New Schema Mapping Project", + "description": "A Schema Mapping Project", + "type": "SCHEMA_MAPPING_RECOMMENDATIONS", + "unifiedDatasetName": "New Schema Mapping Project_unified_dataset", + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "modified version" + }, + "relativeId": "projects/3", + "externalId": "b129f3b1-82f5-4e30-90a3-e562ca977992" + } + } + }, + { + "request": { + "method": "GET", + "path": "projects/3" + }, + "response": { + "status": 200, + "json": { + "id": "unify://unified-data/v1/projects/3", + "name": "New Schema Mapping Project", + "description": "A Schema Mapping Project", + "type": "SCHEMA_MAPPING_RECOMMENDATIONS", + "unifiedDatasetName": "New Schema Mapping Project_unified_dataset", + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "modified version" + }, + "relativeId": "projects/3", + "externalId": "b129f3b1-82f5-4e30-90a3-e562ca977992" + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/test_project/test_create_project_already_exists.json b/tests/tamr_client/fake_json/test_project/test_create_project_already_exists.json new file mode 100644 index 00000000..149ebb6a --- /dev/null +++ b/tests/tamr_client/fake_json/test_project/test_create_project_already_exists.json @@ -0,0 +1,21 @@ +[ + { + "request": { + "method": "POST", + "path": "projects", + "json": { + "name": "New Mastering Project", + "type": "DEDUP", + "unifiedDatasetName": "New Mastering Project_unified_dataset", + "description": "A Mastering Project", + "externalId": null + } + }, + "response": { + "status": 409, + "json": { + "message": "Can't create project with the requested name 'New Mastering Project' because a project with that name already exists" + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/mastering/test_mastering_project.py b/tests/tamr_client/mastering/test_mastering_project.py new file mode 100644 index 00000000..d798996b --- /dev/null +++ b/tests/tamr_client/mastering/test_mastering_project.py @@ -0,0 +1,15 @@ +import tamr_client as tc +from tests.tamr_client import fake + + +@fake.json +def test_create(): + s = fake.session() + instance = fake.instance() + + project = tc.mastering.project.create( + s, instance, name="New Mastering Project", description="A Mastering Project", + ) + assert isinstance(project, tc.MasteringProject) + assert project.name == "New Mastering Project" + assert project.description == "A Mastering Project" diff --git a/tests/tamr_client/schema_mapping/__init__.py b/tests/tamr_client/schema_mapping/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/tamr_client/schema_mapping/test_schema_mapping_project.py b/tests/tamr_client/schema_mapping/test_schema_mapping_project.py new file mode 100644 index 00000000..cf139a00 --- /dev/null +++ b/tests/tamr_client/schema_mapping/test_schema_mapping_project.py @@ -0,0 +1,18 @@ +import tamr_client as tc +from tests.tamr_client import fake + + +@fake.json +def test_create(): + s = fake.session() + instance = fake.instance() + + project = tc.schema_mapping.project.create( + s, + instance, + name="New Schema Mapping Project", + description="A Schema Mapping Project", + ) + assert isinstance(project, tc.SchemaMappingProject) + assert project.name == "New Schema Mapping Project" + assert project.description == "A Schema Mapping Project" diff --git a/tests/tamr_client/test_project.py b/tests/tamr_client/test_project.py index f67d595d..c198add0 100644 --- a/tests/tamr_client/test_project.py +++ b/tests/tamr_client/test_project.py @@ -112,3 +112,26 @@ def test_get_all_filter_list(): assert isinstance(project, tc.CategorizationProject) assert project.name == "project 2" assert project.description == "Categorization Project" + + +@fake.json +def test_create_project_already_exists(): + s = fake.session() + instance = fake.instance() + + with pytest.raises(tc.project.AlreadyExists): + tc.project._create( + s, + instance, + name="New Mastering Project", + project_type="DEDUP", + description="A Mastering Project", + ) + + +def test_from_json_unrecognized_project_type(): + instance = fake.instance() + url = tc.URL("project/1", instance) + data: tc._types.JsonDict = {"type": "NOT_A_PROJECT_TYPE"} + with pytest.raises(ValueError): + tc.project._from_json(url, data) From b96af87d2cd2a230da169c92f2e59d454e5118ef Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 3 Sep 2020 17:11:30 -0400 Subject: [PATCH 590/632] Add test that password is redacted in representation of UsernamePasswordAuth. --- tests/tamr_client/test_auth.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 tests/tamr_client/test_auth.py diff --git a/tests/tamr_client/test_auth.py b/tests/tamr_client/test_auth.py new file mode 100644 index 00000000..b9dbdb1a --- /dev/null +++ b/tests/tamr_client/test_auth.py @@ -0,0 +1,10 @@ +import tamr_client as tc + + +def test_auth_hidden_password(): + username = "username" + password = "secure_password" + auth = tc.UsernamePasswordAuth(username, password) + + assert password not in repr(auth) + assert password not in str(auth) From 892a3d4ccb856faa201eb6198b945ed9bec411b1 Mon Sep 17 00:00:00 2001 From: skalish Date: Sun, 6 Sep 2020 18:33:40 -0400 Subject: [PATCH 591/632] Add first Hello World tutorial. --- docs/beta.md | 3 ++ docs/beta/tutorial/get_version.md | 87 +++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 docs/beta/tutorial/get_version.md diff --git a/docs/beta.md b/docs/beta.md index e1ad360e..74d88cc7 100644 --- a/docs/beta.md +++ b/docs/beta.md @@ -3,6 +3,9 @@ **WARNING**: Do not rely on BETA features in production workflows. Support from Tamr may be limited. +## Tutorials + * [Get Tamr version](beta/tutorial/get_version) + ## Reference * [Attribute](beta/attribute) diff --git a/docs/beta/tutorial/get_version.md b/docs/beta/tutorial/get_version.md new file mode 100644 index 00000000..1cf06174 --- /dev/null +++ b/docs/beta/tutorial/get_version.md @@ -0,0 +1,87 @@ +# Tutorial: Get Tamr version +This tutorial will cover basic Python client usage by guiding you through: +1. Configuring the connection to a Tamr instance +2. Retrieving the version of that instance + +## Prerequisites +To complete this tutorial you will need: +- `tamr-unify-client` [installed](../../user-guide/installation) +- access to a Tamr instance, specifically: + - a username and password that allow you to log in to Tamr + - the socket address of the instance + +The socket address is composed of +1. The protocol, such as `"https"` or `"http"` +2. The host, which may be `"localhost"` if the instance is deployed from the same machine from which your Python code will be run +3. The port at which you access the Tamr user interface, typically `9100` + +When you view the Tamr user interface in a browser, the url is `://:`. If the port is missing, the URL is simply `://host`. + +## Steps +### The Session +The Tamr Python client uses a `Session` to persist the user's authentication details across requests made to the server where Tamr is hosted. + +A `Session` carries authentication credentials derived from a username and password, and is not explicitly tied to any single Tamr instance. For more details, see the documentation for the [Requests library](https://requests.readthedocs.io/en/master/user/advanced/#session-objects). + + - Use your username and password to create an instance of `tamr_client.UsernamePasswordAuth`. + - Use the function `tamr_client.session.from.auth` to create a `Session`. +```python +from getpass import getpass +import tamr_client as tc + +username = input("Tamr Username:") +password = getpass("Tamr Password:") + +auth = tc.UsernamePasswordAuth(username, password) +session = tc.session.from_auth(auth) +``` +### The Instance +An `Instance` models the installation or instance of Tamr with which a user interacts via the Python client. + +- Create an `Instance` using the `protocol`, `host`, and `port` of your Tamr instance. +```python +protocol = "http" +host = "localhost" +port = 9100 + +instance = tc.Instance(protocol=protocol, host=host, port=port) +``` +### Getting the version of Tamr +With the `Session` and `Instance` defined, you can now interact with the API of the Tamr instance. One simple example is fetching the version of the Tamr software running on the server. + +- Use the function `tc.version` and print the returned value. + +```python +print(tc.version(session, instance)) +``` + +All of the above steps can be combined into the following script `get_tamr_version.py`: + +```python +from getpass import getpass +import tamr_client as tc + +username = input("Tamr Username:") +password = getpass("Tamr Password:") + +auth = tc.UsernamePasswordAuth(username, password) +session = tc.session.from_auth(auth) + +protocol = "http" +host = "localhost" +port = 9100 + +instance = tc.Instance(protocol=protocol, host=host, port=port) + +print(tc.version(session, instance)) +``` +To run the script via command line: +```bash +TAMR_CLIENT_BETA=1 python get_tamr_version.py +``` + +If successful, the printed result should be similar to `v2020.016.0`. + +Congratulations! This is just the start of what can be done with the Tamr Python client. + +To continue learning, see other tutorials and examples. \ No newline at end of file From 821a377470d901fb57ac0f3996442c39c707a598 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 10 Sep 2020 12:52:17 -0400 Subject: [PATCH 592/632] Update changelog. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09b26c2d..58eee6ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ - [#452](https://github.com/Datatamer/tamr-client/pull/452) Added functions for creating and deleting a dataset via `tc.dataset.create` and `tc.dataset.delete` - Added function for deleting all records in a dataset via `tc.record.delete_all` - Added functions for getting all datasets and projects in a Tamr instance via `get_all` functions in `tc.dataset` and `tc.project` + - [#454](https://github.com/Datatamer/tamr-client/pull/454) Added first `tamr_client` tutorial "Get Tamr version" + **NEW FEATURES** - [#383](https://github.com/Datatamer/tamr-client/issues/383) Now able to create an Operation from Job resource id From bc7c2d9fc2d1d9bb7566642731d6131c700da78a Mon Sep 17 00:00:00 2001 From: skalish Date: Mon, 14 Sep 2020 17:32:53 -0400 Subject: [PATCH 593/632] Convert code blocks in get_version tutorial to rst and put script in new CI-checked examples directory. --- docs/beta/tutorial/get_version.md | 49 ++++++++---------------- tamr_client/examples/get_tamr_version.py | 17 ++++++++ 2 files changed, 32 insertions(+), 34 deletions(-) create mode 100644 tamr_client/examples/get_tamr_version.py diff --git a/docs/beta/tutorial/get_version.md b/docs/beta/tutorial/get_version.md index 1cf06174..c741dc6e 100644 --- a/docs/beta/tutorial/get_version.md +++ b/docs/beta/tutorial/get_version.md @@ -25,55 +25,36 @@ A `Session` carries authentication credentials derived from a username and passw - Use your username and password to create an instance of `tamr_client.UsernamePasswordAuth`. - Use the function `tamr_client.session.from.auth` to create a `Session`. -```python -from getpass import getpass -import tamr_client as tc - -username = input("Tamr Username:") -password = getpass("Tamr Password:") - -auth = tc.UsernamePasswordAuth(username, password) -session = tc.session.from_auth(auth) +```eval_rst +.. literalinclude:: ../../../tamr_client/examples/get_tamr_version.py + :language: python + :lines: 1-9 ``` ### The Instance An `Instance` models the installation or instance of Tamr with which a user interacts via the Python client. - Create an `Instance` using the `protocol`, `host`, and `port` of your Tamr instance. -```python -protocol = "http" -host = "localhost" -port = 9100 - -instance = tc.Instance(protocol=protocol, host=host, port=port) +```eval_rst +.. literalinclude:: ../../../tamr_client/examples/get_tamr_version.py + :language: python + :lines: 11-15 ``` ### Getting the version of Tamr With the `Session` and `Instance` defined, you can now interact with the API of the Tamr instance. One simple example is fetching the version of the Tamr software running on the server. - Use the function `tc.version` and print the returned value. -```python -print(tc.version(session, instance)) +```eval_rst +.. literalinclude:: ../../../tamr_client/examples/get_tamr_version.py + :language: python + :lines: 17 ``` All of the above steps can be combined into the following script `get_tamr_version.py`: -```python -from getpass import getpass -import tamr_client as tc - -username = input("Tamr Username:") -password = getpass("Tamr Password:") - -auth = tc.UsernamePasswordAuth(username, password) -session = tc.session.from_auth(auth) - -protocol = "http" -host = "localhost" -port = 9100 - -instance = tc.Instance(protocol=protocol, host=host, port=port) - -print(tc.version(session, instance)) +```eval_rst +.. literalinclude:: ../../../tamr_client/examples/get_tamr_version.py + :language: python ``` To run the script via command line: ```bash diff --git a/tamr_client/examples/get_tamr_version.py b/tamr_client/examples/get_tamr_version.py new file mode 100644 index 00000000..55c883fd --- /dev/null +++ b/tamr_client/examples/get_tamr_version.py @@ -0,0 +1,17 @@ +from getpass import getpass + +import tamr_client as tc + +username = input("Tamr Username:") +password = getpass("Tamr Password:") + +auth = tc.UsernamePasswordAuth(username, password) +session = tc.session.from_auth(auth) + +protocol = "http" +host = "localhost" +port = 9100 + +instance = tc.Instance(protocol=protocol, host=host, port=port) + +print(tc.version(session, instance)) From 4da2df3a1ac1c4db35366c7c16c34dca6acb5edd Mon Sep 17 00:00:00 2001 From: skalish Date: Tue, 15 Sep 2020 14:43:49 -0400 Subject: [PATCH 594/632] Add examples to CI tasks and fix function name error in tutorial. --- docs/beta/tutorial/get_version.md | 10 +++++----- {tamr_client/examples => examples}/get_tamr_version.py | 2 +- noxfile.py | 3 +++ 3 files changed, 9 insertions(+), 6 deletions(-) rename {tamr_client/examples => examples}/get_tamr_version.py (87%) diff --git a/docs/beta/tutorial/get_version.md b/docs/beta/tutorial/get_version.md index c741dc6e..a3a23039 100644 --- a/docs/beta/tutorial/get_version.md +++ b/docs/beta/tutorial/get_version.md @@ -26,7 +26,7 @@ A `Session` carries authentication credentials derived from a username and passw - Use your username and password to create an instance of `tamr_client.UsernamePasswordAuth`. - Use the function `tamr_client.session.from.auth` to create a `Session`. ```eval_rst -.. literalinclude:: ../../../tamr_client/examples/get_tamr_version.py +.. literalinclude:: ../../../examples/get_tamr_version.py :language: python :lines: 1-9 ``` @@ -35,17 +35,17 @@ An `Instance` models the installation or instance of Tamr with which a user inte - Create an `Instance` using the `protocol`, `host`, and `port` of your Tamr instance. ```eval_rst -.. literalinclude:: ../../../tamr_client/examples/get_tamr_version.py +.. literalinclude:: ../../../examples/get_tamr_version.py :language: python :lines: 11-15 ``` ### Getting the version of Tamr With the `Session` and `Instance` defined, you can now interact with the API of the Tamr instance. One simple example is fetching the version of the Tamr software running on the server. -- Use the function `tc.version` and print the returned value. +- Use the function `tc.instance.version` and print the returned value. ```eval_rst -.. literalinclude:: ../../../tamr_client/examples/get_tamr_version.py +.. literalinclude:: ../../../examples/get_tamr_version.py :language: python :lines: 17 ``` @@ -53,7 +53,7 @@ With the `Session` and `Instance` defined, you can now interact with the API of All of the above steps can be combined into the following script `get_tamr_version.py`: ```eval_rst -.. literalinclude:: ../../../tamr_client/examples/get_tamr_version.py +.. literalinclude:: ../../../examples/get_tamr_version.py :language: python ``` To run the script via command line: diff --git a/tamr_client/examples/get_tamr_version.py b/examples/get_tamr_version.py similarity index 87% rename from tamr_client/examples/get_tamr_version.py rename to examples/get_tamr_version.py index 55c883fd..e5c6c1b8 100644 --- a/tamr_client/examples/get_tamr_version.py +++ b/examples/get_tamr_version.py @@ -14,4 +14,4 @@ instance = tc.Instance(protocol=protocol, host=host, port=port) -print(tc.version(session, instance)) +print(tc.instance.version(session, instance)) diff --git a/noxfile.py b/noxfile.py index 9b59eb04..3be648bc 100644 --- a/noxfile.py +++ b/noxfile.py @@ -36,6 +36,9 @@ def typecheck(session): tc = repo / "tamr_client" session.run("mypy", "--package", str(tc)) + tc_examples = [str(x) for x in (repo / "examples").glob("**/*.py")] + session.run("mypy", *tc_examples) + tc_tests = [str(x) for x in (repo / "tests" / "tamr_client").glob("**/*.py")] session.run("mypy", *tc_tests) From c76ec25b6e05e93df75fbe11badec7d37e03dd27 Mon Sep 17 00:00:00 2001 From: skalish Date: Tue, 15 Sep 2020 15:52:16 -0400 Subject: [PATCH 595/632] Update changelog. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58eee6ff..1bf31f66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ - Added function for deleting all records in a dataset via `tc.record.delete_all` - Added functions for getting all datasets and projects in a Tamr instance via `get_all` functions in `tc.dataset` and `tc.project` - [#454](https://github.com/Datatamer/tamr-client/pull/454) Added first `tamr_client` tutorial "Get Tamr version" - + - [#456](https://github.com/Datatamer/tamr-client/pull/456) Added first example `tamr_client` script `examples/get_tamr_version.py` **NEW FEATURES** - [#383](https://github.com/Datatamer/tamr-client/issues/383) Now able to create an Operation from Job resource id From 864c9b3cb75becf641ac91ff381d90bab3c1f689 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 1 Oct 2020 15:41:33 -0400 Subject: [PATCH 596/632] Add Golden Records project with update and publish functions. Creation cannot be added because of the lack of a versioned API. --- docs/beta.md | 1 + docs/beta/golden_records.md | 4 ++ docs/beta/golden_records/golden_records.rst | 5 +++ docs/beta/golden_records/project.rst | 4 ++ tamr_client/__init__.py | 2 + tamr_client/_types/__init__.py | 1 + tamr_client/_types/project.py | 21 ++++++++- tamr_client/golden_records/__init__.py | 11 +++++ tamr_client/golden_records/_golden_records.py | 44 +++++++++++++++++++ tamr_client/golden_records/project.py | 17 +++++++ tamr_client/project.py | 3 ++ tests/tamr_client/fake.py | 8 ++++ .../test_publish_async.json | 33 ++++++++++++++ .../test_update_async.json | 33 ++++++++++++++ .../golden_records/test_golden_records.py | 34 ++++++++++++++ 15 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 docs/beta/golden_records.md create mode 100644 docs/beta/golden_records/golden_records.rst create mode 100644 docs/beta/golden_records/project.rst create mode 100644 tamr_client/golden_records/__init__.py create mode 100644 tamr_client/golden_records/_golden_records.py create mode 100644 tamr_client/golden_records/project.py create mode 100644 tests/tamr_client/fake_json/golden_records/test_golden_records/test_publish_async.json create mode 100644 tests/tamr_client/fake_json/golden_records/test_golden_records/test_update_async.json create mode 100644 tests/tamr_client/golden_records/test_golden_records.py diff --git a/docs/beta.md b/docs/beta.md index 74d88cc7..680b15ad 100644 --- a/docs/beta.md +++ b/docs/beta.md @@ -12,6 +12,7 @@ * [Auth](beta/auth) * [Categorization](beta/categorization) * [Dataset](beta/dataset) + * [Golden Records](beta/golden_records) * [Instance](beta/instance) * [Mastering](beta/mastering) * [Operation](beta/operation) diff --git a/docs/beta/golden_records.md b/docs/beta/golden_records.md new file mode 100644 index 00000000..af265d67 --- /dev/null +++ b/docs/beta/golden_records.md @@ -0,0 +1,4 @@ +# Golden Records + + * [Golden Records](/beta/golden_records/golden_records) + * [Project](/beta/golden_records/project) diff --git a/docs/beta/golden_records/golden_records.rst b/docs/beta/golden_records/golden_records.rst new file mode 100644 index 00000000..6736ed3b --- /dev/null +++ b/docs/beta/golden_records/golden_records.rst @@ -0,0 +1,5 @@ +Golden Records +============== + +.. autofunction:: tamr_client.golden_records.update +.. autofunction:: tamr_client.golden_records.publish diff --git a/docs/beta/golden_records/project.rst b/docs/beta/golden_records/project.rst new file mode 100644 index 00000000..a1267890 --- /dev/null +++ b/docs/beta/golden_records/project.rst @@ -0,0 +1,4 @@ +Golden Records Project +====================== + +.. autoclass:: tamr_client.GoldenRecordsProject \ No newline at end of file diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 8913c914..963fb1e8 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -22,6 +22,7 @@ AttributeType, CategorizationProject, Dataset, + GoldenRecordsProject, InputTransformation, Instance, MasteringProject, @@ -42,6 +43,7 @@ from tamr_client import attribute from tamr_client import categorization from tamr_client import dataset +from tamr_client import golden_records from tamr_client import instance from tamr_client import mastering from tamr_client import operation diff --git a/tamr_client/_types/__init__.py b/tamr_client/_types/__init__.py index 36106171..e6157bd4 100644 --- a/tamr_client/_types/__init__.py +++ b/tamr_client/_types/__init__.py @@ -22,6 +22,7 @@ from tamr_client._types.operation import Operation from tamr_client._types.project import ( CategorizationProject, + GoldenRecordsProject, MasteringProject, Project, SchemaMappingProject, diff --git a/tamr_client/_types/project.py b/tamr_client/_types/project.py index c7be346f..03c213b8 100644 --- a/tamr_client/_types/project.py +++ b/tamr_client/_types/project.py @@ -55,4 +55,23 @@ class SchemaMappingProject: description: Optional[str] = None -Project = Union[CategorizationProject, MasteringProject, SchemaMappingProject] +@dataclass(frozen=True) +class GoldenRecordsProject: + """A Tamr Golden Records project + + See https://docs.tamr.com/reference/the-project-object + + Args: + url + name + description + """ + + url: URL + name: str + description: Optional[str] = None + + +Project = Union[ + CategorizationProject, MasteringProject, SchemaMappingProject, GoldenRecordsProject +] diff --git a/tamr_client/golden_records/__init__.py b/tamr_client/golden_records/__init__.py new file mode 100644 index 00000000..623262b7 --- /dev/null +++ b/tamr_client/golden_records/__init__.py @@ -0,0 +1,11 @@ +""" +Tamr - Golden Records +See https://docs.tamr.com/docs/overview-golden-records +""" +from tamr_client.golden_records import project +from tamr_client.golden_records._golden_records import ( + _publish_async, + _update_async, + publish, + update, +) diff --git a/tamr_client/golden_records/_golden_records.py b/tamr_client/golden_records/_golden_records.py new file mode 100644 index 00000000..7118a168 --- /dev/null +++ b/tamr_client/golden_records/_golden_records.py @@ -0,0 +1,44 @@ +""" +Tamr - Golden Records +See https://docs.tamr.com/docs/overview-golden-records + +The terminology used here is consistent with Tamr UI terminology + +Asynchronous versions of each function can be found with the suffix `_async` and may be of +interest to power users +""" +from tamr_client import operation +from tamr_client._types import GoldenRecordsProject, Operation, Session + + +def update(session: Session, project: GoldenRecordsProject) -> Operation: + """Update the draft golden records and wait for the operation to complete + + Args: + project: Tamr Golden Records project + """ + op = _update_async(session, project) + return operation.wait(session, op) + + +def publish(session: Session, project: GoldenRecordsProject) -> Operation: + """Publish the golden records and wait for the operation to complete + + Args: + project: Tamr Golden Records project + """ + op = _publish_async(session, project) + return operation.wait(session, op) + + +def _update_async(session: Session, project: GoldenRecordsProject) -> Operation: + r = session.post(str(project.url) + "/goldenRecords:refresh") + return operation._from_response(project.url.instance, r) + + +def _publish_async(session: Session, project: GoldenRecordsProject) -> Operation: + r = session.post( + str(project.url) + "/publishedGoldenRecords:refresh", + params={"validate": "true", "version": "CURRENT"}, + ) + return operation._from_response(project.url.instance, r) diff --git a/tamr_client/golden_records/project.py b/tamr_client/golden_records/project.py new file mode 100644 index 00000000..bce0bfe6 --- /dev/null +++ b/tamr_client/golden_records/project.py @@ -0,0 +1,17 @@ +from tamr_client._types import ( + GoldenRecordsProject, + JsonDict, + URL, +) + + +def _from_json(url: URL, data: JsonDict) -> GoldenRecordsProject: + """Make golden records project from JSON data (deserialize) + + Args: + url: Project URL + data: Project JSON data from Tamr server + """ + return GoldenRecordsProject( + url, name=data["name"], description=data.get("description") + ) diff --git a/tamr_client/project.py b/tamr_client/project.py index 3535d027..00e24242 100644 --- a/tamr_client/project.py +++ b/tamr_client/project.py @@ -4,6 +4,7 @@ from tamr_client._types import Instance, JsonDict, Project, Session, URL from tamr_client.categorization import project as categorization_project from tamr_client.exception import TamrClientException +from tamr_client.golden_records import project as golden_records_project from tamr_client.mastering import project as mastering_project from tamr_client.schema_mapping import project as schema_mapping_project @@ -106,6 +107,8 @@ def _from_json(url: URL, data: JsonDict) -> Project: return categorization_project._from_json(url, data) elif proj_type == "SCHEMA_MAPPING_RECOMMENDATIONS": return schema_mapping_project._from_json(url, data) + elif proj_type == "GOLDEN_RECORDS": + return golden_records_project._from_json(url, data) else: raise ValueError(f"Unrecognized project type '{proj_type}' in {repr(data)}") diff --git a/tests/tamr_client/fake.py b/tests/tamr_client/fake.py index 9da3ab66..ed2133be 100644 --- a/tests/tamr_client/fake.py +++ b/tests/tamr_client/fake.py @@ -172,6 +172,14 @@ def categorization_project() -> tc.CategorizationProject: return categorization_project +def golden_records_project() -> tc.GoldenRecordsProject: + url = tc.URL(path="projects/3") + golden_records_project = tc.GoldenRecordsProject( + url, name="Project 3", description="A Golden Records Project" + ) + return golden_records_project + + def transforms() -> tc.Transformations: return tc.Transformations( input_scope=[ diff --git a/tests/tamr_client/fake_json/golden_records/test_golden_records/test_publish_async.json b/tests/tamr_client/fake_json/golden_records/test_golden_records/test_publish_async.json new file mode 100644 index 00000000..e660817e --- /dev/null +++ b/tests/tamr_client/fake_json/golden_records/test_golden_records/test_publish_async.json @@ -0,0 +1,33 @@ +[ + { + "request": { + "method": "POST", + "path": "projects/3/publishedGoldenRecords:refresh?validate=true&version=CURRENT" + }, + "response": { + "status": 200, + "json": { + "id": "1", + "type": "SPARK", + "description": "Updating published datasets for GoldenRecords module", + "status": { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark" + }, + "created": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 modified version" + }, + "relativeId": "operations/1" + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/golden_records/test_golden_records/test_update_async.json b/tests/tamr_client/fake_json/golden_records/test_golden_records/test_update_async.json new file mode 100644 index 00000000..c6cb0b4c --- /dev/null +++ b/tests/tamr_client/fake_json/golden_records/test_golden_records/test_update_async.json @@ -0,0 +1,33 @@ +[ + { + "request": { + "method": "POST", + "path": "projects/3/goldenRecords:refresh" + }, + "response": { + "status": 200, + "json": { + "id": "1", + "type": "SPARK", + "description": "Updating Golden Records", + "status": { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark" + }, + "created": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 modified version" + }, + "relativeId": "operations/1" + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/golden_records/test_golden_records.py b/tests/tamr_client/golden_records/test_golden_records.py new file mode 100644 index 00000000..c28e6c8e --- /dev/null +++ b/tests/tamr_client/golden_records/test_golden_records.py @@ -0,0 +1,34 @@ +import tamr_client as tc +from tests.tamr_client import fake + + +@fake.json +def test_update_async(): + s = fake.session() + project = fake.golden_records_project() + + op = tc.golden_records._update_async(s, project) + assert op.type == "SPARK" + assert op.description == "Updating Golden Records" + assert op.status == { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark", + } + + +@fake.json +def test_publish_async(): + s = fake.session() + project = fake.golden_records_project() + + op = tc.golden_records._publish_async(s, project) + assert op.type == "SPARK" + assert op.description == "Updating published datasets for GoldenRecords module" + assert op.status == { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark", + } From 484416749fd084dda53902a04753720b20a656d6 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 14 Oct 2020 14:02:13 -0400 Subject: [PATCH 597/632] Update changelog. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bf31f66..aabdab09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Added functions for getting all datasets and projects in a Tamr instance via `get_all` functions in `tc.dataset` and `tc.project` - [#454](https://github.com/Datatamer/tamr-client/pull/454) Added first `tamr_client` tutorial "Get Tamr version" - [#456](https://github.com/Datatamer/tamr-client/pull/456) Added first example `tamr_client` script `examples/get_tamr_version.py` + - [#461](https://github.com/Datatamer/tamr-client/pull/461) Added functions for golden record workflow operations in `tc.golden_records` **NEW FEATURES** - [#383](https://github.com/Datatamer/tamr-client/issues/383) Now able to create an Operation from Job resource id From f7d86992a35b4f017accd9714e52ca23ef783126 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 14 Oct 2020 20:09:57 -0400 Subject: [PATCH 598/632] Add operation.check utility Raises exception when operation has failed --- CHANGELOG.md | 1 + docs/beta/operation.rst | 12 +++++++++- tamr_client/operation.py | 23 ++++++++++++++++++++ tests/tamr_client/data/operation_failed.json | 22 +++++++++++++++++++ tests/tamr_client/test_operation.py | 22 +++++++++++++++++++ 5 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 tests/tamr_client/data/operation_failed.json diff --git a/CHANGELOG.md b/CHANGELOG.md index aabdab09..7bee92bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - [#454](https://github.com/Datatamer/tamr-client/pull/454) Added first `tamr_client` tutorial "Get Tamr version" - [#456](https://github.com/Datatamer/tamr-client/pull/456) Added first example `tamr_client` script `examples/get_tamr_version.py` - [#461](https://github.com/Datatamer/tamr-client/pull/461) Added functions for golden record workflow operations in `tc.golden_records` + - [#462](https://github.com/Datatamer/tamr-client/issues/462) Added function for checking operations, raising exception when operation has failed **NEW FEATURES** - [#383](https://github.com/Datatamer/tamr-client/issues/383) Now able to create an Operation from Job resource id diff --git a/docs/beta/operation.rst b/docs/beta/operation.rst index 4a339902..09574708 100644 --- a/docs/beta/operation.rst +++ b/docs/beta/operation.rst @@ -3,7 +3,17 @@ Operation .. autoclass:: tamr_client.Operation +.. autofunction:: tamr_client.operation.check .. autofunction:: tamr_client.operation.poll .. autofunction:: tamr_client.operation.wait .. autofunction:: tamr_client.operation.succeeded -.. autofunction:: tamr_client.operation.by_resource_id \ No newline at end of file +.. autofunction:: tamr_client.operation.by_resource_id + +Exceptions +---------- + +.. autoclass:: tamr_client.operation.Failed + :no-inherited-members: + +.. autoclass:: tamr_client.operation.NotFound + :no-inherited-members: \ No newline at end of file diff --git a/tamr_client/operation.py b/tamr_client/operation.py index fa7ff3b3..01f9e751 100644 --- a/tamr_client/operation.py +++ b/tamr_client/operation.py @@ -19,6 +19,29 @@ class NotFound(TamrClientException): pass +class Failed(TamrClientException): + """Raised when checking a failed operation. + """ + + pass + + +def check(session: Session, operation: Operation): + """Waits for the operation to finish and raises an exception if the operation was not successful. + + Args: + operation: Operation to be checked. + + Raises: + Failed: If the operation failed. + """ + op = wait(session, operation) + if not succeeded(op): + raise Failed( + f"Checked operation '{str(op.url)}', but it failed with status: {op.status}" + ) + + def poll(session: Session, operation: Operation) -> Operation: """Poll this operation for server-side updates. diff --git a/tests/tamr_client/data/operation_failed.json b/tests/tamr_client/data/operation_failed.json new file mode 100644 index 00000000..17c196f7 --- /dev/null +++ b/tests/tamr_client/data/operation_failed.json @@ -0,0 +1,22 @@ +{ + "id": "1", + "type": "SPARK", + "description": "operation 1 description", + "status": { + "state": "FAILED", + "startTime": "", + "endTime": "", + "message": "" + }, + "created": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 modified version" + }, + "relativeId": "operations/1" +} \ No newline at end of file diff --git a/tests/tamr_client/test_operation.py b/tests/tamr_client/test_operation.py index f42be25d..8ea7b271 100644 --- a/tests/tamr_client/test_operation.py +++ b/tests/tamr_client/test_operation.py @@ -110,3 +110,25 @@ def test_operation_poll(): assert op2.url == op1.url assert not tc.operation.succeeded(op1) assert tc.operation.succeeded(op2) + + +def test_operation_check_success(): + s = fake.session() + url = tc.URL(path="operations/1") + op_json = utils.load_json("operation_succeeded.json") + op = tc.operation._from_json(url, op_json) + + tc.operation.check(s, op) + + +def test_operation_failed_success(): + s = fake.session() + url = tc.URL(path="operations/1") + op_json = utils.load_json("operation_failed.json") + op = tc.operation._from_json(url, op_json) + + with pytest.raises(tc.operation.Failed) as exc_info: + tc.operation.check(s, op) + err_msg = str(exc_info.value) + assert str(url) in err_msg + assert op.status is not None and str(op.status["state"]) in err_msg From db04e4e136863b5d1e741e0297724eddf15b315d Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 16 Oct 2020 12:58:59 -0400 Subject: [PATCH 599/632] Add skalish to maintainer list --- docs/contributor-guide.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/contributor-guide.md b/docs/contributor-guide.md index 5dfafed6..d6a5645c 100644 --- a/docs/contributor-guide.md +++ b/docs/contributor-guide.md @@ -38,6 +38,7 @@ Maintainer responsabilities: Current maintainers: - [pcattori](https://github.com/pcattori) +- [skalish](https://github.com/skalish) Want to become a maintainer? Open a pull request that adds your name to the list of current maintainers! \ No newline at end of file From 23e4a9b27da54030bc061ac1f2772869909ea24e Mon Sep 17 00:00:00 2001 From: skalish Date: Sun, 25 Oct 2020 19:40:54 -0400 Subject: [PATCH 600/632] Remove dependency on simplejson and use a custom encoder when upserting with ignore_nan=True. --- poetry.lock | 33 +- pyproject.toml | 1 - tamr_unify_client/_custom_encoder.py | 457 ++++++++++++++++++++++++ tamr_unify_client/dataset/collection.py | 8 +- tamr_unify_client/dataset/resource.py | 31 +- tests/unit/test_dataset_records.py | 55 ++- 6 files changed, 521 insertions(+), 64 deletions(-) create mode 100644 tamr_unify_client/_custom_encoder.py diff --git a/poetry.lock b/poetry.lock index 2d329265..f9b8bcf0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -445,14 +445,6 @@ six = "*" [package.extras] tests = ["pytest", "coverage (>=3.7.1,<5.0.0)", "pytest-cov", "pytest-localserver", "flake8"] -[[package]] -category = "main" -description = "Simple, fast, extensible JSON encoder/decoder for Python" -name = "simplejson" -optional = false -python-versions = ">=2.5, !=3.0.*, !=3.1.*, !=3.2.*" -version = "3.16.0" - [[package]] category = "dev" description = "Python 2 and 3 compatibility utilities" @@ -549,7 +541,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["pathlib2", "contextlib2", "unittest2"] [metadata] -content-hash = "eb1a510dfdc339cc884ea39cc411306e4593c01a46c13bb15e4aaf9fff85cad5" +content-hash = "891a70e0fce285518c0ac4b762d18e79ccc02d48163ad526d9553cc0895e26e8" python-versions = "^3.6.1" [metadata.files] @@ -773,29 +765,6 @@ responses = [ {file = "responses-0.10.6-py2.py3-none-any.whl", hash = "sha256:97193c0183d63fba8cd3a041c75464e4b09ea0aff6328800d1546598567dde0b"}, {file = "responses-0.10.6.tar.gz", hash = "sha256:502d9c0c8008439cfcdef7e251f507fcfdd503b56e8c0c87c3c3e3393953f790"}, ] -simplejson = [ - {file = "simplejson-3.16.0-cp27-cp27m-macosx_10_12_x86_64.whl", hash = "sha256:6c3258ffff58712818a233b9737fe4be943d306c40cf63d14ddc82ba563f483a"}, - {file = "simplejson-3.16.0-cp27-cp27m-win32.whl", hash = "sha256:2fc546e6af49fb45b93bbe878dea4c48edc34083729c0abd09981fe55bdf7f91"}, - {file = "simplejson-3.16.0-cp27-cp27m-win_amd64.whl", hash = "sha256:3b919fc9cf508f13b929a9b274c40786036b31ad28657819b3b9ba44ba651f50"}, - {file = "simplejson-3.16.0-cp33-cp33m-win32.whl", hash = "sha256:3af610ee72efbe644e19d5eaad575c73fb83026192114e5f6719f4901097fce2"}, - {file = "simplejson-3.16.0-cp33-cp33m-win_amd64.whl", hash = "sha256:fb2530b53c28f0d4d84990e945c2ebb470edb469d63e389bf02ff409012fe7c5"}, - {file = "simplejson-3.16.0-cp34-cp34m-win32.whl", hash = "sha256:37e685986cf6f8144607f90340cff72d36acf654f3653a6c47b84c5c38d00df7"}, - {file = "simplejson-3.16.0-cp34-cp34m-win_amd64.whl", hash = "sha256:ee9625fc8ee164902dfbb0ff932b26df112da9f871c32f0f9c1bcf20c350fe2a"}, - {file = "simplejson-3.16.0-cp35-cp35m-macosx_10_12_x86_64.whl", hash = "sha256:354fa32b02885e6dae925f1b5bbf842c333c1e11ea5453ddd67309dc31fdb40a"}, - {file = "simplejson-3.16.0-cp35-cp35m-win32.whl", hash = "sha256:3dd289368bbd064974d9a5961101f080e939cbe051e6689a193c99fb6e9ac89b"}, - {file = "simplejson-3.16.0-cp35-cp35m-win_amd64.whl", hash = "sha256:067a7177ddfa32e1483ba5169ebea1bc2ea27f224853211ca669325648ca5642"}, - {file = "simplejson-3.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:75e3f0b12c28945c08f54350d91e624f8dd580ab74fd4f1bbea54bc6b0165610"}, - {file = "simplejson-3.16.0.tar.gz", hash = "sha256:b1f329139ba647a9548aa05fb95d046b4a677643070dc2afc05fa2e975d09ca5"}, - {file = "simplejson-3.16.0.win-amd64-py2.7.exe", hash = "sha256:495511fe5f10ccf4e3ed4fc0c48318f533654db6c47ecbc970b4ed215c791968"}, - {file = "simplejson-3.16.0.win-amd64-py3.3.exe", hash = "sha256:d8e238f20bcf70063ee8691d4a72162bcec1f4c38f83c93e6851e72ad545dabb"}, - {file = "simplejson-3.16.0.win-amd64-py3.4.exe", hash = "sha256:feadb95170e45f439455354904768608e356c5b174ca30b3d11b0e3f24b5c0df"}, - {file = "simplejson-3.16.0.win-amd64-py3.5.exe", hash = "sha256:65b41a5cda006cfa7c66eabbcf96aa704a6be2a5856095b9e2fd8c293bad2b46"}, - {file = "simplejson-3.16.0.win-amd64-py3.6.exe", hash = "sha256:c206f47cbf9f32b573c9885f0ec813d2622976cf5effcf7e472344bc2e020ac1"}, - {file = "simplejson-3.16.0.win32-py2.7.exe", hash = "sha256:491de7acc423e871a814500eb2dcea8aa66c4a4b1b4825d18f756cdf58e370cb"}, - {file = "simplejson-3.16.0.win32-py3.3.exe", hash = "sha256:79b129fe65fdf3765440f7a73edaffc89ae9e7885d4e2adafe6aa37913a00fbb"}, - {file = "simplejson-3.16.0.win32-py3.4.exe", hash = "sha256:2b8cb601d9ba0381499db719ccc9dfbb2fbd16013f5ff096b1a68a4775576a04"}, - {file = "simplejson-3.16.0.win32-py3.5.exe", hash = "sha256:2c139daf167b96f21542248f8e0a06596c9b9a7a41c162cc5c9ee9f3833c93cd"}, -] six = [ {file = "six-1.12.0-py2.py3-none-any.whl", hash = "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c"}, {file = "six-1.12.0.tar.gz", hash = "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"}, diff --git a/pyproject.toml b/pyproject.toml index 7b6845e5..8d64a4ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,6 @@ include = ["tamr_client/py.typed"] [tool.poetry.dependencies] python = "^3.6.1" requests = "^2.22" -simplejson = "^3.16" dataclasses = "^0.6.0" [tool.poetry.dev-dependencies] diff --git a/tamr_unify_client/_custom_encoder.py b/tamr_unify_client/_custom_encoder.py new file mode 100644 index 00000000..977ac460 --- /dev/null +++ b/tamr_unify_client/_custom_encoder.py @@ -0,0 +1,457 @@ +# flake8: noqa: C901 +"""Implementation of JSONEncoder +""" +import re + +try: + from _json import encode_basestring_ascii as c_encode_basestring_ascii +except ImportError: + c_encode_basestring_ascii = None +try: + from _json import encode_basestring as c_encode_basestring +except ImportError: + c_encode_basestring = None +try: + from _json import make_encoder as c_make_encoder +except ImportError: + c_make_encoder = None + +ESCAPE = re.compile(r'[\x00-\x1f\\"\b\f\n\r\t]') +ESCAPE_ASCII = re.compile(r'([\\"]|[^\ -~])') +HAS_UTF8 = re.compile(b"[\x80-\xff]") +ESCAPE_DCT = { + "\\": "\\\\", + '"': '\\"', + "\b": "\\b", + "\f": "\\f", + "\n": "\\n", + "\r": "\\r", + "\t": "\\t", +} +for i in range(0x20): + ESCAPE_DCT.setdefault(chr(i), "\\u{0:04x}".format(i)) + # ESCAPE_DCT.setdefault(chr(i), '\\u%04x' % (i,)) + +INFINITY = float("inf") + + +def py_encode_basestring(s): + """Return a JSON representation of a Python string + """ + + def replace(match): + return ESCAPE_DCT[match.group(0)] + + return '"' + ESCAPE.sub(replace, s) + '"' + + +encode_basestring = c_encode_basestring or py_encode_basestring + + +def py_encode_basestring_ascii(s): + """Return an ASCII-only JSON representation of a Python string + """ + + def replace(match): + s = match.group(0) + try: + return ESCAPE_DCT[s] + except KeyError: + n = ord(s) + if n < 0x10000: + return "\\u{0:04x}".format(n) + # return '\\u%04x' % (n,) + else: + # surrogate pair + n -= 0x10000 + s1 = 0xD800 | ((n >> 10) & 0x3FF) + s2 = 0xDC00 | (n & 0x3FF) + return "\\u{0:04x}\\u{1:04x}".format(s1, s2) + + return '"' + ESCAPE_ASCII.sub(replace, s) + '"' + + +encode_basestring_ascii = c_encode_basestring_ascii or py_encode_basestring_ascii + + +class JSONEncoder(object): + """Extensible JSON encoder for Python data structures. + Supports the following objects and types by default: + +-------------------+---------------+ + | Python | JSON | + +===================+===============+ + | dict | object | + +-------------------+---------------+ + | list, tuple | array | + +-------------------+---------------+ + | str | string | + +-------------------+---------------+ + | int, float | number | + +-------------------+---------------+ + | True | true | + +-------------------+---------------+ + | False | false | + +-------------------+---------------+ + | None | null | + +-------------------+---------------+ + To extend this to recognize other objects, subclass and implement a + ``.default()`` method with another method that returns a serializable + object for ``o`` if possible, otherwise it should call the superclass + implementation (to raise ``TypeError``). + """ + + item_separator = ", " + key_separator = ": " + + def __init__( + self, + *, + skipkeys=False, + ensure_ascii=True, + check_circular=True, + allow_nan=True, + sort_keys=False, + indent=None, + separators=None, + default=None, + ): + """Constructor for JSONEncoder, with sensible defaults. + If skipkeys is false, then it is a TypeError to attempt + encoding of keys that are not str, int, float or None. If + skipkeys is True, such items are simply skipped. + If ensure_ascii is true, the output is guaranteed to be str + objects with all incoming non-ASCII characters escaped. If + ensure_ascii is false, the output can contain non-ASCII characters. + If check_circular is true, then lists, dicts, and custom encoded + objects will be checked for circular references during encoding to + prevent an infinite recursion (which would cause an OverflowError). + Otherwise, no such check takes place. + If allow_nan is true, then NaN, Infinity, and -Infinity will be + encoded as such. This behavior is not JSON specification compliant, + but is consistent with most JavaScript based encoders and decoders. + Otherwise, it will be a ValueError to encode such floats. + If sort_keys is true, then the output of dictionaries will be + sorted by key; this is useful for regression tests to ensure + that JSON serializations can be compared on a day-to-day basis. + If indent is a non-negative integer, then JSON array + elements and object members will be pretty-printed with that + indent level. An indent level of 0 will only insert newlines. + None is the most compact representation. + If specified, separators should be an (item_separator, key_separator) + tuple. The default is (', ', ': ') if *indent* is ``None`` and + (',', ': ') otherwise. To get the most compact JSON representation, + you should specify (',', ':') to eliminate whitespace. + If specified, default is a function that gets called for objects + that can't otherwise be serialized. It should return a JSON encodable + version of the object or raise a ``TypeError``. + """ + + self.skipkeys = skipkeys + self.ensure_ascii = ensure_ascii + self.check_circular = check_circular + self.allow_nan = allow_nan + self.sort_keys = sort_keys + self.indent = indent + if separators is not None: + self.item_separator, self.key_separator = separators + elif indent is not None: + self.item_separator = "," + if default is not None: + self.default = default + + def default(self, o): + """Implement this method in a subclass such that it returns + a serializable object for ``o``, or calls the base implementation + (to raise a ``TypeError``). + For example, to support arbitrary iterators, you could + implement default like this:: + def default(self, o): + try: + iterable = iter(o) + except TypeError: + pass + else: + return list(iterable) + # Let the base class default method raise the TypeError + return JSONEncoder.default(self, o) + """ + raise TypeError( + f"Object of type {o.__class__.__name__} " f"is not JSON serializable" + ) + + def encode(self, o): + """Return a JSON string representation of a Python data structure. + >>> from json.encoder import JSONEncoder + >>> JSONEncoder().encode({"foo": ["bar", "baz"]}) + '{"foo": ["bar", "baz"]}' + """ + # This is for extremely simple cases and benchmarks. + if isinstance(o, str): + if self.ensure_ascii: + return encode_basestring_ascii(o) + else: + return encode_basestring(o) + # This doesn't pass the iterator directly to ''.join() because the + # exceptions aren't as detailed. The list call should be roughly + # equivalent to the PySequence_Fast that ''.join() would do. + chunks = self.iterencode(o, _one_shot=True) + if not isinstance(chunks, (list, tuple)): + chunks = list(chunks) + return "".join(chunks) + + def iterencode(self, o, _one_shot=False): + """Encode the given object and yield each string + representation as available. + For example:: + for chunk in JSONEncoder().iterencode(bigobject): + mysocket.write(chunk) + """ + if self.check_circular: + markers = {} + else: + markers = None + if self.ensure_ascii: + _encoder = encode_basestring_ascii + else: + _encoder = encode_basestring + + def floatstr( + o, + allow_nan=self.allow_nan, + _repr=float.__repr__, + _inf=INFINITY, + _neginf=-INFINITY, + ): + # Check for specials. Note that this type of test is processor + # and/or platform-specific, so do tests which don't depend on the + # internals. + + if o != o or o == _inf or o == _neginf: + return "null" + else: + return _repr(o) + + if _one_shot and c_make_encoder is not None and self.indent is None: + _iterencode = c_make_encoder( + markers, + self.default, + _encoder, + self.indent, + self.key_separator, + self.item_separator, + self.sort_keys, + self.skipkeys, + self.allow_nan, + ) + else: + _iterencode = _make_iterencode( + markers, + self.default, + _encoder, + self.indent, + floatstr, + self.key_separator, + self.item_separator, + self.sort_keys, + self.skipkeys, + _one_shot, + ) + return _iterencode(o, 0) + + +def _make_iterencode( + markers, + _default, + _encoder, + _indent, + _floatstr, + _key_separator, + _item_separator, + _sort_keys, + _skipkeys, + _one_shot, + ## HACK: hand-optimized bytecode; turn globals into locals + ValueError=ValueError, + dict=dict, + float=float, + id=id, + int=int, + isinstance=isinstance, + list=list, + str=str, + tuple=tuple, + _intstr=int.__repr__, +): + + if _indent is not None and not isinstance(_indent, str): + _indent = " " * _indent + + def _iterencode_list(lst, _current_indent_level): + if not lst: + yield "[]" + return + if markers is not None: + markerid = id(lst) + if markerid in markers: + raise ValueError("Circular reference detected") + markers[markerid] = lst + buf = "[" + if _indent is not None: + _current_indent_level += 1 + newline_indent = "\n" + _indent * _current_indent_level + separator = _item_separator + newline_indent + buf += newline_indent + else: + newline_indent = None + separator = _item_separator + first = True + for value in lst: + if first: + first = False + else: + buf = separator + if isinstance(value, str): + yield buf + _encoder(value) + elif value is None: + yield buf + "null" + elif value is True: + yield buf + "true" + elif value is False: + yield buf + "false" + elif isinstance(value, int): + # Subclasses of int/float may override __repr__, but we still + # want to encode them as integers/floats in JSON. One example + # within the standard library is IntEnum. + yield buf + _intstr(value) + elif isinstance(value, float): + # see comment above for int + yield buf + _floatstr(value) + else: + yield buf + if isinstance(value, (list, tuple)): + chunks = _iterencode_list(value, _current_indent_level) + elif isinstance(value, dict): + chunks = _iterencode_dict(value, _current_indent_level) + else: + chunks = _iterencode(value, _current_indent_level) + yield from chunks + if newline_indent is not None: + _current_indent_level -= 1 + yield "\n" + _indent * _current_indent_level + yield "]" + if markers is not None: + del markers[markerid] + + def _iterencode_dict(dct, _current_indent_level): + if not dct: + yield "{}" + return + if markers is not None: + markerid = id(dct) + if markerid in markers: + raise ValueError("Circular reference detected") + markers[markerid] = dct + yield "{" + if _indent is not None: + _current_indent_level += 1 + newline_indent = "\n" + _indent * _current_indent_level + item_separator = _item_separator + newline_indent + yield newline_indent + else: + newline_indent = None + item_separator = _item_separator + first = True + if _sort_keys: + items = sorted(dct.items()) + else: + items = dct.items() + for key, value in items: + if isinstance(key, str): + pass + # JavaScript is weakly typed for these, so it makes sense to + # also allow them. Many encoders seem to do something like this. + elif isinstance(key, float): + # see comment for int/float in _make_iterencode + key = _floatstr(key) + elif key is True: + key = "true" + elif key is False: + key = "false" + elif key is None: + key = "null" + elif isinstance(key, int): + # see comment for int/float in _make_iterencode + key = _intstr(key) + elif _skipkeys: + continue + else: + raise TypeError( + f"keys must be str, int, float, bool or None, " + f"not {key.__class__.__name__}" + ) + if first: + first = False + else: + yield item_separator + yield _encoder(key) + yield _key_separator + if isinstance(value, str): + yield _encoder(value) + elif value is None: + yield "null" + elif value is True: + yield "true" + elif value is False: + yield "false" + elif isinstance(value, int): + # see comment for int/float in _make_iterencode + yield _intstr(value) + elif isinstance(value, float): + # see comment for int/float in _make_iterencode + yield _floatstr(value) + else: + if isinstance(value, (list, tuple)): + chunks = _iterencode_list(value, _current_indent_level) + elif isinstance(value, dict): + chunks = _iterencode_dict(value, _current_indent_level) + else: + chunks = _iterencode(value, _current_indent_level) + yield from chunks + if newline_indent is not None: + _current_indent_level -= 1 + yield "\n" + _indent * _current_indent_level + yield "}" + if markers is not None: + del markers[markerid] + + def _iterencode(o, _current_indent_level): + if isinstance(o, str): + yield _encoder(o) + elif o is None: + yield "null" + elif o is True: + yield "true" + elif o is False: + yield "false" + elif isinstance(o, int): + # see comment for int/float in _make_iterencode + yield _intstr(o) + elif isinstance(o, float): + # see comment for int/float in _make_iterencode + yield _floatstr(o) + elif isinstance(o, (list, tuple)): + yield from _iterencode_list(o, _current_indent_level) + elif isinstance(o, dict): + yield from _iterencode_dict(o, _current_indent_level) + else: + if markers is not None: + markerid = id(o) + if markerid in markers: + raise ValueError("Circular reference detected") + markers[markerid] = o + o = _default(o) + yield from _iterencode(o, _current_indent_level) + if markers is not None: + del markers[markerid] + + return _iterencode diff --git a/tamr_unify_client/dataset/collection.py b/tamr_unify_client/dataset/collection.py index 3119dc8d..967a8972 100644 --- a/tamr_unify_client/dataset/collection.py +++ b/tamr_unify_client/dataset/collection.py @@ -125,8 +125,7 @@ def create_from_dataframe( :type primary_key_name: str :param dataset_name: What to name the dataset in Tamr. There cannot already be a dataset with this name. :type dataset_name: str - :param ignore_nan: Whether to convert `NaN` values to `null` before upserting records to Tamr. If `False` and - `NaN` is in `df`, this function will fail. Optional, default is `True`. + :param ignore_nan: Legacy parameter that does nothing :type ignore_nan: bool :returns: The newly created dataset. :rtype: :class:`~tamr_unify_client.dataset.resource.Dataset` @@ -158,10 +157,9 @@ def create_from_dataframe( except HTTPError: self._handle_creation_failure(dataset, "An attribute was not created") - records = df.to_dict(orient="records") try: - response = dataset.upsert_records( - records, primary_key_name, ignore_nan=ignore_nan + response = dataset.upsert_from_dataframe( + df, primary_key_name=primary_key_name ) except HTTPError: self._handle_creation_failure(dataset, "Records could not be created") diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index ee3be3b4..8b5a6bf3 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -1,9 +1,9 @@ from copy import deepcopy +import json import os from typing import TYPE_CHECKING -import simplejson as json - +from tamr_unify_client._custom_encoder import JSONEncoder from tamr_unify_client.attribute.collection import AttributeCollection from tamr_unify_client.base_resource import BaseResource from tamr_unify_client.dataset.profile import DatasetProfile @@ -64,20 +64,21 @@ def attributes(self): alias = self.api_path + "/attributes" return AttributeCollection(self.client, alias) - def _update_records(self, updates, **json_args): + def _update_records(self, updates, ignore_nan=False): """Send a batch of record creations/updates/deletions to this dataset. You probably want to use :func:`~tamr_unify_client.dataset.resource.Dataset.upsert_records` or :func:`~tamr_unify_client.dataset.resource.Dataset.delete_records` instead. :param records: Each record should be formatted as specified in the `Public Docs for Dataset updates `_. :type records: iterable[dict] - :param `**json_args`: Arguments to pass to the JSON `dumps` function, as documented `here `_. - Some of these, such as `indent`, may not work with Tamr. + :param ignore_nan: Whether to treat `NaN` values as null. Unconverted `NaN`s will raise an error if found. + :type ignore_nan: bool :returns: JSON response body from server. :rtype: :py:class:`dict` """ + encoder = JSONEncoder if ignore_nan else None stringified_updates = ( - json.dumps(update, **json_args).encode("utf-8") for update in updates + json.dumps(update, cls=encoder).encode("utf-8") for update in updates ) return ( @@ -98,7 +99,7 @@ def upsert_from_dataframe( Args: df: The data to upsert records from. primary_key_name: The name of the primary key of the dataset. Must be a column of `df`. - ignore_nan: Whether to convert `NaN` values to `null` before upserting records to Tamr. If `False` and `NaN` is in `df`, this function will fail. Optional, default is `True`. + ignore_nan: Legacy parameter that does nothing Returns: JSON response body from the server. @@ -110,18 +111,22 @@ def upsert_from_dataframe( if primary_key_name not in df.columns: raise KeyError(f"{primary_key_name} is not an attribute of the data") - records = df.to_dict(orient="records") - return self.upsert_records(records, primary_key_name, ignore_nan=ignore_nan) + # serialize records via to_json to handle `np.nan` values + serialized_records = ((pk, row.to_json()) for pk, row in df.iterrows()) + records = ( + {primary_key_name: pk, **json.loads(row)} for pk, row in serialized_records + ) + return self.upsert_records(records, primary_key_name) - def upsert_records(self, records, primary_key_name, **json_args): + def upsert_records(self, records, primary_key_name, ignore_nan=False): """Creates or updates the specified records. :param records: The records to update, as dictionaries. :type records: iterable[dict] :param primary_key_name: The name of the primary key for these records, which must be a key in each record dictionary. :type primary_key_name: str - :param `**json_args`: Arguments to pass to the JSON `dumps` function, as documented `here `_. - Some of these, such as `indent`, may not work with Tamr. + :param ignore_nan: Whether to convert `NaN` values to `null` when upserting records. If `False` and `NaN` is found this function will fail. + :type ignore_nan: bool :return: JSON response body from the server. :rtype: dict """ @@ -129,7 +134,7 @@ def upsert_records(self, records, primary_key_name, **json_args): {"action": "CREATE", "recordId": record[primary_key_name], "record": record} for record in records ) - return self._update_records(updates, **json_args) + return self._update_records(updates, ignore_nan=ignore_nan) def delete_records(self, records, primary_key_name): """Deletes the specified records. diff --git a/tests/unit/test_dataset_records.py b/tests/unit/test_dataset_records.py index c611269f..1de806a1 100644 --- a/tests/unit/test_dataset_records.py +++ b/tests/unit/test_dataset_records.py @@ -1,12 +1,13 @@ from functools import partial +import json from unittest import TestCase from pandas import DataFrame from requests.exceptions import HTTPError import responses -import simplejson from tamr_unify_client import Client +from tamr_unify_client._custom_encoder import JSONEncoder from tamr_unify_client.auth import UsernamePasswordAuth @@ -22,7 +23,7 @@ def test_get(self): responses.add( responses.GET, records_url, - body="\n".join([simplejson.dumps(x) for x in self._records_json]), + body="\n".join([json.dumps(x) for x in self._records_json]), ) dataset = self.tamr.datasets.by_resource_id(self._dataset_id) @@ -33,7 +34,7 @@ def test_get(self): def test_update(self): def create_callback(request, snoop): snoop["payload"] = list(request.body) - return 200, {}, simplejson.dumps(self._response_json) + return 200, {}, json.dumps(self._response_json) responses.add(responses.GET, self._dataset_url, json={}) dataset = self.tamr.datasets.by_resource_id(self._dataset_id) @@ -53,7 +54,7 @@ def create_callback(request, snoop): def test_nan_update(self): def create_callback(request, snoop, status): snoop["payload"] = list(request.body) - return status, {}, simplejson.dumps(self._response_json) + return status, {}, json.dumps(self._response_json) responses.add(responses.GET, self._dataset_url, json={}) dataset = self.tamr.datasets.by_resource_id(self._dataset_id) @@ -84,7 +85,7 @@ def create_callback(request, snoop, status): def test_upsert(self): def create_callback(request, snoop): snoop["payload"] = list(request.body) - return 200, {}, simplejson.dumps(self._response_json) + return 200, {}, json.dumps(self._response_json) responses.add(responses.GET, self._dataset_url, json={}) dataset = self.tamr.datasets.by_resource_id(self._dataset_id) @@ -104,7 +105,7 @@ def create_callback(request, snoop): def test_upsert_from_dataframe(self): def create_callback(request, snoop): snoop["payload"] = list(request.body) - return 200, {}, simplejson.dumps(self._response_json) + return 200, {}, json.dumps(self._response_json) responses.add(responses.GET, self._dataset_url, json={}) dataset = self.tamr.datasets.by_resource_id(self._dataset_id) @@ -122,11 +123,33 @@ def create_callback(request, snoop): self.assertEqual(response, self._response_json) self.assertEqual(snoop["payload"], TestDatasetRecords.stringify(updates, False)) + @responses.activate + def test_upsert_from_dataframe_nan(self): + def create_callback(request, snoop): + snoop["payload"] = list(request.body) + return 200, {}, json.dumps(self._response_json) + + responses.add(responses.GET, self._dataset_url, json={}) + dataset = self.tamr.datasets.by_resource_id(self._dataset_id) + + records_url = f"{self._dataset_url}:updateRecords" + updates = TestDatasetRecords.records_to_updates(self._null_records_json) + snoop = {} + responses.add_callback( + responses.POST, records_url, partial(create_callback, snoop=snoop) + ) + + response = dataset.upsert_from_dataframe( + self._dataframe_nan, primary_key_name="pk" + ) + self.assertEqual(response, self._response_json) + self.assertEqual(snoop["payload"], TestDatasetRecords.stringify(updates, True)) + @responses.activate def test_delete(self): def create_callback(request, snoop): snoop["payload"] = list(request.body) - return 200, {}, simplejson.dumps(self._response_json) + return 200, {}, json.dumps(self._response_json) responses.add(responses.GET, self._dataset_url, json={}) dataset = self.tamr.datasets.by_resource_id(self._dataset_id) @@ -146,7 +169,7 @@ def create_callback(request, snoop): def test_delete_ids(self): def create_callback(request, snoop): snoop["payload"] = list(request.body) - return 200, {}, simplejson.dumps(self._response_json) + return 200, {}, json.dumps(self._response_json) responses.add(responses.GET, self._dataset_url, json={}) dataset = self.tamr.datasets.by_resource_id(self._dataset_id) @@ -188,16 +211,22 @@ def records_to_updates(records): @staticmethod def stringify(updates, ignore_nan): - return [ - simplejson.dumps(u, ignore_nan=ignore_nan).encode("utf-8") for u in updates - ] + encoder = JSONEncoder if ignore_nan else None + return [json.dumps(u, cls=encoder).encode("utf-8") for u in updates] _dataset_id = "1" _dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/{_dataset_id}" _records_json = [{"attribute1": 1}, {"attribute1": 2}] - _dataframe = DataFrame(_records_json, columns=["attribute1"]) - _nan_records_json = [{"attribute1": float("nan")}, {"attribute1": float("nan")}] + _dataframe = DataFrame(_records_json, columns=["attribute1"], dtype=object) + _nan_records_json = [ + {"pk": 1, "attribute1": float("nan")}, + {"pk": 2, "attribute1": float("nan")}, + ] + _dataframe_nan = DataFrame( + _nan_records_json, columns=["pk", "attribute1"], dtype=object + ) + _null_records_json = [{"pk": 1, "attribute1": None}, {"pk": 2, "attribute1": None}] _response_json = { "numCommandsProcessed": 2, "allCommandsSucceeded": True, From b200ceb836e435c2a79157057ce28ee318f81b0a Mon Sep 17 00:00:00 2001 From: skalish Date: Mon, 26 Oct 2020 11:09:23 -0400 Subject: [PATCH 601/632] Add module-level docstring to _custom_encoder.py --- tamr_unify_client/_custom_encoder.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tamr_unify_client/_custom_encoder.py b/tamr_unify_client/_custom_encoder.py index 977ac460..53924e14 100644 --- a/tamr_unify_client/_custom_encoder.py +++ b/tamr_unify_client/_custom_encoder.py @@ -1,5 +1,7 @@ # flake8: noqa: C901 -"""Implementation of JSONEncoder +"""Adaptation of the Python standard library JSONEncoder to encode `NaN` as 'null' +Compare to https://github.com/python/cpython/blob/3.9/Lib/json/encoder.py +The only functional difference is in the definition of `floatstr` where 'NaN', 'Infinity', and '-Infinity' are encoded as 'null' """ import re From b51ba7dff329d09ca08cf249c9ccdf941d38aa1b Mon Sep 17 00:00:00 2001 From: skalish Date: Mon, 26 Oct 2020 14:36:52 -0400 Subject: [PATCH 602/632] Simplify _custom_encoder.py --- tamr_unify_client/_custom_encoder.py | 432 ++------------------------ tamr_unify_client/dataset/resource.py | 4 +- 2 files changed, 22 insertions(+), 414 deletions(-) diff --git a/tamr_unify_client/_custom_encoder.py b/tamr_unify_client/_custom_encoder.py index 53924e14..660dba19 100644 --- a/tamr_unify_client/_custom_encoder.py +++ b/tamr_unify_client/_custom_encoder.py @@ -1,9 +1,13 @@ -# flake8: noqa: C901 """Adaptation of the Python standard library JSONEncoder to encode `NaN` as 'null' Compare to https://github.com/python/cpython/blob/3.9/Lib/json/encoder.py The only functional difference is in the definition of `floatstr` where 'NaN', 'Infinity', and '-Infinity' are encoded as 'null' """ -import re +from json import JSONEncoder +from json.encoder import ( + _make_iterencode, + py_encode_basestring, + py_encode_basestring_ascii, +) try: from _json import encode_basestring_ascii as c_encode_basestring_ascii @@ -18,189 +22,12 @@ except ImportError: c_make_encoder = None -ESCAPE = re.compile(r'[\x00-\x1f\\"\b\f\n\r\t]') -ESCAPE_ASCII = re.compile(r'([\\"]|[^\ -~])') -HAS_UTF8 = re.compile(b"[\x80-\xff]") -ESCAPE_DCT = { - "\\": "\\\\", - '"': '\\"', - "\b": "\\b", - "\f": "\\f", - "\n": "\\n", - "\r": "\\r", - "\t": "\\t", -} -for i in range(0x20): - ESCAPE_DCT.setdefault(chr(i), "\\u{0:04x}".format(i)) - # ESCAPE_DCT.setdefault(chr(i), '\\u%04x' % (i,)) - INFINITY = float("inf") - - -def py_encode_basestring(s): - """Return a JSON representation of a Python string - """ - - def replace(match): - return ESCAPE_DCT[match.group(0)] - - return '"' + ESCAPE.sub(replace, s) + '"' - - encode_basestring = c_encode_basestring or py_encode_basestring - - -def py_encode_basestring_ascii(s): - """Return an ASCII-only JSON representation of a Python string - """ - - def replace(match): - s = match.group(0) - try: - return ESCAPE_DCT[s] - except KeyError: - n = ord(s) - if n < 0x10000: - return "\\u{0:04x}".format(n) - # return '\\u%04x' % (n,) - else: - # surrogate pair - n -= 0x10000 - s1 = 0xD800 | ((n >> 10) & 0x3FF) - s2 = 0xDC00 | (n & 0x3FF) - return "\\u{0:04x}\\u{1:04x}".format(s1, s2) - - return '"' + ESCAPE_ASCII.sub(replace, s) + '"' - - encode_basestring_ascii = c_encode_basestring_ascii or py_encode_basestring_ascii -class JSONEncoder(object): - """Extensible JSON encoder for Python data structures. - Supports the following objects and types by default: - +-------------------+---------------+ - | Python | JSON | - +===================+===============+ - | dict | object | - +-------------------+---------------+ - | list, tuple | array | - +-------------------+---------------+ - | str | string | - +-------------------+---------------+ - | int, float | number | - +-------------------+---------------+ - | True | true | - +-------------------+---------------+ - | False | false | - +-------------------+---------------+ - | None | null | - +-------------------+---------------+ - To extend this to recognize other objects, subclass and implement a - ``.default()`` method with another method that returns a serializable - object for ``o`` if possible, otherwise it should call the superclass - implementation (to raise ``TypeError``). - """ - - item_separator = ", " - key_separator = ": " - - def __init__( - self, - *, - skipkeys=False, - ensure_ascii=True, - check_circular=True, - allow_nan=True, - sort_keys=False, - indent=None, - separators=None, - default=None, - ): - """Constructor for JSONEncoder, with sensible defaults. - If skipkeys is false, then it is a TypeError to attempt - encoding of keys that are not str, int, float or None. If - skipkeys is True, such items are simply skipped. - If ensure_ascii is true, the output is guaranteed to be str - objects with all incoming non-ASCII characters escaped. If - ensure_ascii is false, the output can contain non-ASCII characters. - If check_circular is true, then lists, dicts, and custom encoded - objects will be checked for circular references during encoding to - prevent an infinite recursion (which would cause an OverflowError). - Otherwise, no such check takes place. - If allow_nan is true, then NaN, Infinity, and -Infinity will be - encoded as such. This behavior is not JSON specification compliant, - but is consistent with most JavaScript based encoders and decoders. - Otherwise, it will be a ValueError to encode such floats. - If sort_keys is true, then the output of dictionaries will be - sorted by key; this is useful for regression tests to ensure - that JSON serializations can be compared on a day-to-day basis. - If indent is a non-negative integer, then JSON array - elements and object members will be pretty-printed with that - indent level. An indent level of 0 will only insert newlines. - None is the most compact representation. - If specified, separators should be an (item_separator, key_separator) - tuple. The default is (', ', ': ') if *indent* is ``None`` and - (',', ': ') otherwise. To get the most compact JSON representation, - you should specify (',', ':') to eliminate whitespace. - If specified, default is a function that gets called for objects - that can't otherwise be serialized. It should return a JSON encodable - version of the object or raise a ``TypeError``. - """ - - self.skipkeys = skipkeys - self.ensure_ascii = ensure_ascii - self.check_circular = check_circular - self.allow_nan = allow_nan - self.sort_keys = sort_keys - self.indent = indent - if separators is not None: - self.item_separator, self.key_separator = separators - elif indent is not None: - self.item_separator = "," - if default is not None: - self.default = default - - def default(self, o): - """Implement this method in a subclass such that it returns - a serializable object for ``o``, or calls the base implementation - (to raise a ``TypeError``). - For example, to support arbitrary iterators, you could - implement default like this:: - def default(self, o): - try: - iterable = iter(o) - except TypeError: - pass - else: - return list(iterable) - # Let the base class default method raise the TypeError - return JSONEncoder.default(self, o) - """ - raise TypeError( - f"Object of type {o.__class__.__name__} " f"is not JSON serializable" - ) - - def encode(self, o): - """Return a JSON string representation of a Python data structure. - >>> from json.encoder import JSONEncoder - >>> JSONEncoder().encode({"foo": ["bar", "baz"]}) - '{"foo": ["bar", "baz"]}' - """ - # This is for extremely simple cases and benchmarks. - if isinstance(o, str): - if self.ensure_ascii: - return encode_basestring_ascii(o) - else: - return encode_basestring(o) - # This doesn't pass the iterator directly to ''.join() because the - # exceptions aren't as detailed. The list call should be roughly - # equivalent to the PySequence_Fast that ''.join() would do. - chunks = self.iterencode(o, _one_shot=True) - if not isinstance(chunks, (list, tuple)): - chunks = list(chunks) - return "".join(chunks) - +class IgnoreNanEncoder(JSONEncoder): def iterencode(self, o, _one_shot=False): """Encode the given object and yield each string representation as available. @@ -218,242 +45,23 @@ def iterencode(self, o, _one_shot=False): _encoder = encode_basestring def floatstr( - o, - allow_nan=self.allow_nan, - _repr=float.__repr__, - _inf=INFINITY, - _neginf=-INFINITY, + o, _repr=float.__repr__, _inf=INFINITY, _neginf=-INFINITY, ): - # Check for specials. Note that this type of test is processor - # and/or platform-specific, so do tests which don't depend on the - # internals. - if o != o or o == _inf or o == _neginf: return "null" else: return _repr(o) - if _one_shot and c_make_encoder is not None and self.indent is None: - _iterencode = c_make_encoder( - markers, - self.default, - _encoder, - self.indent, - self.key_separator, - self.item_separator, - self.sort_keys, - self.skipkeys, - self.allow_nan, - ) - else: - _iterencode = _make_iterencode( - markers, - self.default, - _encoder, - self.indent, - floatstr, - self.key_separator, - self.item_separator, - self.sort_keys, - self.skipkeys, - _one_shot, - ) + _iterencode = _make_iterencode( + markers, + self.default, + _encoder, + self.indent, + floatstr, + self.key_separator, + self.item_separator, + self.sort_keys, + self.skipkeys, + _one_shot, + ) return _iterencode(o, 0) - - -def _make_iterencode( - markers, - _default, - _encoder, - _indent, - _floatstr, - _key_separator, - _item_separator, - _sort_keys, - _skipkeys, - _one_shot, - ## HACK: hand-optimized bytecode; turn globals into locals - ValueError=ValueError, - dict=dict, - float=float, - id=id, - int=int, - isinstance=isinstance, - list=list, - str=str, - tuple=tuple, - _intstr=int.__repr__, -): - - if _indent is not None and not isinstance(_indent, str): - _indent = " " * _indent - - def _iterencode_list(lst, _current_indent_level): - if not lst: - yield "[]" - return - if markers is not None: - markerid = id(lst) - if markerid in markers: - raise ValueError("Circular reference detected") - markers[markerid] = lst - buf = "[" - if _indent is not None: - _current_indent_level += 1 - newline_indent = "\n" + _indent * _current_indent_level - separator = _item_separator + newline_indent - buf += newline_indent - else: - newline_indent = None - separator = _item_separator - first = True - for value in lst: - if first: - first = False - else: - buf = separator - if isinstance(value, str): - yield buf + _encoder(value) - elif value is None: - yield buf + "null" - elif value is True: - yield buf + "true" - elif value is False: - yield buf + "false" - elif isinstance(value, int): - # Subclasses of int/float may override __repr__, but we still - # want to encode them as integers/floats in JSON. One example - # within the standard library is IntEnum. - yield buf + _intstr(value) - elif isinstance(value, float): - # see comment above for int - yield buf + _floatstr(value) - else: - yield buf - if isinstance(value, (list, tuple)): - chunks = _iterencode_list(value, _current_indent_level) - elif isinstance(value, dict): - chunks = _iterencode_dict(value, _current_indent_level) - else: - chunks = _iterencode(value, _current_indent_level) - yield from chunks - if newline_indent is not None: - _current_indent_level -= 1 - yield "\n" + _indent * _current_indent_level - yield "]" - if markers is not None: - del markers[markerid] - - def _iterencode_dict(dct, _current_indent_level): - if not dct: - yield "{}" - return - if markers is not None: - markerid = id(dct) - if markerid in markers: - raise ValueError("Circular reference detected") - markers[markerid] = dct - yield "{" - if _indent is not None: - _current_indent_level += 1 - newline_indent = "\n" + _indent * _current_indent_level - item_separator = _item_separator + newline_indent - yield newline_indent - else: - newline_indent = None - item_separator = _item_separator - first = True - if _sort_keys: - items = sorted(dct.items()) - else: - items = dct.items() - for key, value in items: - if isinstance(key, str): - pass - # JavaScript is weakly typed for these, so it makes sense to - # also allow them. Many encoders seem to do something like this. - elif isinstance(key, float): - # see comment for int/float in _make_iterencode - key = _floatstr(key) - elif key is True: - key = "true" - elif key is False: - key = "false" - elif key is None: - key = "null" - elif isinstance(key, int): - # see comment for int/float in _make_iterencode - key = _intstr(key) - elif _skipkeys: - continue - else: - raise TypeError( - f"keys must be str, int, float, bool or None, " - f"not {key.__class__.__name__}" - ) - if first: - first = False - else: - yield item_separator - yield _encoder(key) - yield _key_separator - if isinstance(value, str): - yield _encoder(value) - elif value is None: - yield "null" - elif value is True: - yield "true" - elif value is False: - yield "false" - elif isinstance(value, int): - # see comment for int/float in _make_iterencode - yield _intstr(value) - elif isinstance(value, float): - # see comment for int/float in _make_iterencode - yield _floatstr(value) - else: - if isinstance(value, (list, tuple)): - chunks = _iterencode_list(value, _current_indent_level) - elif isinstance(value, dict): - chunks = _iterencode_dict(value, _current_indent_level) - else: - chunks = _iterencode(value, _current_indent_level) - yield from chunks - if newline_indent is not None: - _current_indent_level -= 1 - yield "\n" + _indent * _current_indent_level - yield "}" - if markers is not None: - del markers[markerid] - - def _iterencode(o, _current_indent_level): - if isinstance(o, str): - yield _encoder(o) - elif o is None: - yield "null" - elif o is True: - yield "true" - elif o is False: - yield "false" - elif isinstance(o, int): - # see comment for int/float in _make_iterencode - yield _intstr(o) - elif isinstance(o, float): - # see comment for int/float in _make_iterencode - yield _floatstr(o) - elif isinstance(o, (list, tuple)): - yield from _iterencode_list(o, _current_indent_level) - elif isinstance(o, dict): - yield from _iterencode_dict(o, _current_indent_level) - else: - if markers is not None: - markerid = id(o) - if markerid in markers: - raise ValueError("Circular reference detected") - markers[markerid] = o - o = _default(o) - yield from _iterencode(o, _current_indent_level) - if markers is not None: - del markers[markerid] - - return _iterencode diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index 8b5a6bf3..6baa7d43 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -3,7 +3,7 @@ import os from typing import TYPE_CHECKING -from tamr_unify_client._custom_encoder import JSONEncoder +from tamr_unify_client._custom_encoder import IgnoreNanEncoder from tamr_unify_client.attribute.collection import AttributeCollection from tamr_unify_client.base_resource import BaseResource from tamr_unify_client.dataset.profile import DatasetProfile @@ -76,7 +76,7 @@ def _update_records(self, updates, ignore_nan=False): :returns: JSON response body from server. :rtype: :py:class:`dict` """ - encoder = JSONEncoder if ignore_nan else None + encoder = IgnoreNanEncoder if ignore_nan else None stringified_updates = ( json.dumps(update, cls=encoder).encode("utf-8") for update in updates ) From 9967243825b67c4502f78b59c1581d1526c80253 Mon Sep 17 00:00:00 2001 From: skalish Date: Mon, 26 Oct 2020 15:26:46 -0400 Subject: [PATCH 603/632] Set allow_nan=False to catch NaN's before making an update request. --- tamr_unify_client/dataset/resource.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index 6baa7d43..7fc41671 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -78,7 +78,8 @@ def _update_records(self, updates, ignore_nan=False): """ encoder = IgnoreNanEncoder if ignore_nan else None stringified_updates = ( - json.dumps(update, cls=encoder).encode("utf-8") for update in updates + json.dumps(update, cls=encoder, allow_nan=False).encode("utf-8") + for update in updates ) return ( From 0ea4c4e0e0a052f094548ff8baac2e24e66fa176 Mon Sep 17 00:00:00 2001 From: skalish Date: Mon, 26 Oct 2020 15:26:58 -0400 Subject: [PATCH 604/632] Update tests to check for NaN handling. --- tests/unit/test_dataset_records.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/tests/unit/test_dataset_records.py b/tests/unit/test_dataset_records.py index 1de806a1..df385f83 100644 --- a/tests/unit/test_dataset_records.py +++ b/tests/unit/test_dataset_records.py @@ -3,11 +3,9 @@ from unittest import TestCase from pandas import DataFrame -from requests.exceptions import HTTPError import responses from tamr_unify_client import Client -from tamr_unify_client._custom_encoder import JSONEncoder from tamr_unify_client.auth import UsernamePasswordAuth @@ -63,20 +61,16 @@ def create_callback(request, snoop, status): updates = TestDatasetRecords.records_to_updates(self._nan_records_json) snoop = {} - responses.add_callback( - responses.POST, - records_url, - partial(create_callback, snoop=snoop, status=400), - ) responses.add_callback( responses.POST, records_url, partial(create_callback, snoop=snoop, status=200), ) - self.assertRaises(HTTPError, lambda: dataset._update_records(updates)) - self.assertEqual(snoop["payload"], TestDatasetRecords.stringify(updates, False)) + # First call raises a ValueError and makes no request because NaN is not valid JSON + self.assertRaises(ValueError, lambda: dataset._update_records(updates)) + # Second call has payload with NaN replaced by null and makes a successful request response = dataset._update_records(updates, ignore_nan=True) self.assertEqual(response, self._response_json) self.assertEqual(snoop["payload"], TestDatasetRecords.stringify(updates, True)) @@ -211,8 +205,8 @@ def records_to_updates(records): @staticmethod def stringify(updates, ignore_nan): - encoder = JSONEncoder if ignore_nan else None - return [json.dumps(u, cls=encoder).encode("utf-8") for u in updates] + nan_fill = "null" if ignore_nan else "NaN" + return [json.dumps(u).replace("NaN", nan_fill).encode("utf-8") for u in updates] _dataset_id = "1" _dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/{_dataset_id}" From e02e2288ca0eca9450de5f76842be238712bf38a Mon Sep 17 00:00:00 2001 From: skalish Date: Mon, 26 Oct 2020 16:08:29 -0400 Subject: [PATCH 605/632] Add deprecation warnings. --- tamr_unify_client/dataset/collection.py | 9 ++++++++- tamr_unify_client/dataset/resource.py | 24 ++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/tamr_unify_client/dataset/collection.py b/tamr_unify_client/dataset/collection.py index 967a8972..fe07deb6 100644 --- a/tamr_unify_client/dataset/collection.py +++ b/tamr_unify_client/dataset/collection.py @@ -1,3 +1,5 @@ +import warnings + from requests.exceptions import HTTPError from tamr_unify_client.base_collection import BaseCollection @@ -108,7 +110,7 @@ def create(self, creation_spec): return Dataset.from_json(self.client, data) def create_from_dataframe( - self, df, primary_key_name, dataset_name, ignore_nan=True + self, df, primary_key_name, dataset_name, ignore_nan=None ): """Creates a dataset in this collection with the given name, creates an attribute for each column in the `df` (with `primary_key_name` as the key attribute), and upserts a record for each row of `df`. @@ -132,6 +134,11 @@ def create_from_dataframe( :raises KeyError: If `primary_key_name` is not a column in `df`. :raises CreationError: If a step in creating the dataset fails. """ + if ignore_nan is not None: + warnings.warn( + "'ignore_nan' is deprecated. DataFrame `NaN`s are always ignored in upsert", + DeprecationWarning, + ) if primary_key_name not in df.columns: raise KeyError(f"{primary_key_name} is not an attribute of the data") diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index 7fc41671..e840887f 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -1,7 +1,8 @@ from copy import deepcopy import json import os -from typing import TYPE_CHECKING +from typing import Optional, TYPE_CHECKING +import warnings from tamr_unify_client._custom_encoder import IgnoreNanEncoder from tamr_unify_client.attribute.collection import AttributeCollection @@ -76,6 +77,11 @@ def _update_records(self, updates, ignore_nan=False): :returns: JSON response body from server. :rtype: :py:class:`dict` """ + if ignore_nan: + warnings.warn( + "'ignore_nan' is deprecated. Users are expected to provide valid JSON representations instead", + DeprecationWarning, + ) encoder = IgnoreNanEncoder if ignore_nan else None stringified_updates = ( json.dumps(update, cls=encoder, allow_nan=False).encode("utf-8") @@ -93,7 +99,11 @@ def _update_records(self, updates, ignore_nan=False): ) def upsert_from_dataframe( - self, df: "pd.DataFrame", *, primary_key_name: str, ignore_nan: bool = True + self, + df: "pd.DataFrame", + *, + primary_key_name: str, + ignore_nan: Optional[bool] = None, ) -> dict: """Upserts a record for each row of `df` with attributes for each column in `df`. @@ -109,6 +119,11 @@ def upsert_from_dataframe( KeyError: If `primary_key_name` is not a column in `df`. """ + if ignore_nan is not None: + warnings.warn( + "'ignore_nan' is deprecated. DataFrame `NaN`s are always ignored in upsert", + DeprecationWarning, + ) if primary_key_name not in df.columns: raise KeyError(f"{primary_key_name} is not an attribute of the data") @@ -131,6 +146,11 @@ def upsert_records(self, records, primary_key_name, ignore_nan=False): :return: JSON response body from the server. :rtype: dict """ + if ignore_nan: + warnings.warn( + "'ignore_nan' is deprecated. Users are expected to provide valid JSON representations instead", + DeprecationWarning, + ) updates = ( {"action": "CREATE", "recordId": record[primary_key_name], "record": record} for record in records From a33ac72c5eaacd48475cdde33d7602a174c977bc Mon Sep 17 00:00:00 2001 From: skalish Date: Tue, 27 Oct 2020 12:02:34 -0400 Subject: [PATCH 606/632] Rename _custom_encoder.py to more descriptive _ignore_nan_encoder.py --- .../{_custom_encoder.py => _ignore_nan_encoder.py} | 0 tamr_unify_client/dataset/resource.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename tamr_unify_client/{_custom_encoder.py => _ignore_nan_encoder.py} (100%) diff --git a/tamr_unify_client/_custom_encoder.py b/tamr_unify_client/_ignore_nan_encoder.py similarity index 100% rename from tamr_unify_client/_custom_encoder.py rename to tamr_unify_client/_ignore_nan_encoder.py diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index e840887f..09b4a9c8 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -4,7 +4,7 @@ from typing import Optional, TYPE_CHECKING import warnings -from tamr_unify_client._custom_encoder import IgnoreNanEncoder +from tamr_unify_client._ignore_nan_encoder import IgnoreNanEncoder from tamr_unify_client.attribute.collection import AttributeCollection from tamr_unify_client.base_resource import BaseResource from tamr_unify_client.dataset.profile import DatasetProfile From bbfdf70a2f497113795e1f16bd01a04c77c791a8 Mon Sep 17 00:00:00 2001 From: skalish Date: Tue, 27 Oct 2020 12:39:55 -0400 Subject: [PATCH 607/632] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bee92bd..ff49509c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,10 @@ **NEW FEATURES** - [#383](https://github.com/Datatamer/tamr-client/issues/383) Now able to create an Operation from Job resource id + **BREAKING CHANGES** + - [#468](https://github.com/Datatamer/tamr-client/pull/468) `Dataset.upsert_records` and `Dataset._update_records` no longer take general `**json_args` arguments and will only accept `ignore_nan` + - The `ignore_nan` argument in `Dataset.upsert_records`, `Dataset._update_records`, `Dataset.upsert_from_dataframe`, and `DatasetCollection.create_from_dataframe` is now deprecated and will be removed in a future release + ## 0.12.0 **BETA** Important: Do not use BETA features for production workflows. From 11a6869e460fbb6e9e4bbae13d35c58eaa5e16b8 Mon Sep 17 00:00:00 2001 From: skalish Date: Tue, 27 Oct 2020 12:56:57 -0400 Subject: [PATCH 608/632] Make ignore_nan keyword-only and note that it is deprecated in function docstrings. --- tamr_unify_client/dataset/resource.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index 09b4a9c8..1291ee03 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -65,14 +65,14 @@ def attributes(self): alias = self.api_path + "/attributes" return AttributeCollection(self.client, alias) - def _update_records(self, updates, ignore_nan=False): + def _update_records(self, updates, *, ignore_nan=False): """Send a batch of record creations/updates/deletions to this dataset. You probably want to use :func:`~tamr_unify_client.dataset.resource.Dataset.upsert_records` or :func:`~tamr_unify_client.dataset.resource.Dataset.delete_records` instead. :param records: Each record should be formatted as specified in the `Public Docs for Dataset updates `_. :type records: iterable[dict] - :param ignore_nan: Whether to treat `NaN` values as null. Unconverted `NaN`s will raise an error if found. + :param ignore_nan: Whether to treat `NaN` values as null. Unconverted `NaN`s will raise an error if found. Deprecated. :type ignore_nan: bool :returns: JSON response body from server. :rtype: :py:class:`dict` @@ -110,7 +110,7 @@ def upsert_from_dataframe( Args: df: The data to upsert records from. primary_key_name: The name of the primary key of the dataset. Must be a column of `df`. - ignore_nan: Legacy parameter that does nothing + ignore_nan: Legacy parameter that does nothing. Deprecated. Returns: JSON response body from the server. @@ -134,14 +134,14 @@ def upsert_from_dataframe( ) return self.upsert_records(records, primary_key_name) - def upsert_records(self, records, primary_key_name, ignore_nan=False): + def upsert_records(self, records, primary_key_name, *, ignore_nan=False): """Creates or updates the specified records. :param records: The records to update, as dictionaries. :type records: iterable[dict] :param primary_key_name: The name of the primary key for these records, which must be a key in each record dictionary. :type primary_key_name: str - :param ignore_nan: Whether to convert `NaN` values to `null` when upserting records. If `False` and `NaN` is found this function will fail. + :param ignore_nan: Whether to convert `NaN` values to `null` when upserting records. If `False` and `NaN` is found this function will fail. Deprecated. :type ignore_nan: bool :return: JSON response body from the server. :rtype: dict From 23b3283392132d9961057e6e5e0a2616d00ef1ef Mon Sep 17 00:00:00 2001 From: skalish Date: Mon, 26 Oct 2020 18:29:39 -0400 Subject: [PATCH 609/632] Add tamr-client continuous mastering tutorial. --- docs/beta.md | 1 + docs/beta/tutorial/continuous_mastering.md | 111 +++++++++++++++++++++ examples/continuous_mastering.py | 41 ++++++++ 3 files changed, 153 insertions(+) create mode 100644 docs/beta/tutorial/continuous_mastering.md create mode 100644 examples/continuous_mastering.py diff --git a/docs/beta.md b/docs/beta.md index 680b15ad..8ca6dc2f 100644 --- a/docs/beta.md +++ b/docs/beta.md @@ -5,6 +5,7 @@ ## Tutorials * [Get Tamr version](beta/tutorial/get_version) + * [Continuous Mastering](beta/tutorial/continuous_mastering) ## Reference diff --git a/docs/beta/tutorial/continuous_mastering.md b/docs/beta/tutorial/continuous_mastering.md new file mode 100644 index 00000000..6e1ee417 --- /dev/null +++ b/docs/beta/tutorial/continuous_mastering.md @@ -0,0 +1,111 @@ +# Tutorial: Continuous Mastering +This tutorial will cover using the Python client to keep a Mastering project up-to-date. This includes carrying new data through to the end of the project and using any new labels to update the machine-learning model. + +## Prerequisites +To complete this tutorial you will need: +- `tamr-unify-client` [installed](../../user-guide/installation) +- access to a Tamr instance, specifically: + - a username and password that allow you to log in to Tamr + - the socket address of the instance +- an existing Mastering project with loaded data that has been run to the end at least once + - it is recommended that you first complete the tutorial [here](https://docs.tamr.com/tamr-tutorials/docs/overview-mastering) + - alternatively, a different Mastering project can be used however the project name will likely be different + +## Steps +### Configure the Session and Instance +- Use your username and password to create an instance of `tamr_client.UsernamePasswordAuth`. +- Use the function `tamr_client.session.from.auth` to create a `Session`. +```eval_rst +.. literalinclude:: ../../../examples/continuous_mastering.py + :language: python + :lines: 1-9 +``` +- Create an `Instance` using the `protocol`, `host`, and `port` of your Tamr instance. Replace the values of `protocol`, `host`, and `port` with the corresponding values for your Tamr instance. +```eval_rst +.. literalinclude:: ../../../examples/continuous_mastering.py + :language: python + :lines: 11-15 +``` + +### Get the Tamr Mastering project to be updated +Use the function `tc.project.by_name` to retrieve the project information from the server. +```eval_rst +.. literalinclude:: ../../../examples/continuous_mastering.py + :language: python + :lines: 17 +``` +Ensure that the retrieved project is a Mastering project by checking its type: +```eval_rst +.. literalinclude:: ../../../examples/continuous_mastering.py + :language: python + :lines: 19-20 +``` + +### Update the unified dataset +To apply the [attribute mapping configuration](https://docs.tamr.com/tamr-tutorials/docs/define-project-schema-mastering) and any transformations to update the unified dataset with updated source data, use the function `tc.mastering.update_unified_dataset`. +```eval_rst +.. literalinclude:: ../../../examples/continuous_mastering.py + :language: python + :lines: 22-23 +``` +This function and all others in this tutorial are *synchronous*, meaning that they will not return until the job in Tamr has resolved, either successfully or unsuccessfully. The function `tc.operation.check` will raise an exception and halt the script if the job started in Tamr fails for any reason. + +### Generate pairs +To generate pairs according to the [configured pair filter rules](https://docs.tamr.com/tamr-tutorials/docs/setup-how-pairs-are-found), use the function `tc.mastering.generate_pairs` +```eval_rst +.. literalinclude:: ../../../examples/continuous_mastering.py + :language: python + :lines: 25-26 +``` + +### Train the model with new Labels +To [update the machine-learning model](https://docs.tamr.com/tamr-tutorials/docs/help-tamr-learn-about-your-data) with newly-applied pairs. use the function `tc.mastering.apply_feedback` +```eval_rst +.. literalinclude:: ../../../examples/continuous_mastering.py + :language: python + :lines: 28-29 +``` +Note: The "Apply feedback and update results" action in the Tamr GUI is equivalent to this and the following section. + +### Apply the model +Applying the trained machine-learning model requires three functions. +- To update the pair prediction results, use the function `tc.mastering.update_pair_results` +```eval_rst +.. literalinclude:: ../../../examples/continuous_mastering.py + :language: python + :lines: 31-32 +``` +- To update the list of [high-impact pairs](https://docs.tamr.com/tamr-tutorials/docs/help-tamr-learn-about-your-data#4-filter-for-high-impact-pairs), use the function `tc.mastering.update_high_impact_pairs` +```eval_rst +.. literalinclude:: ../../../examples/continuous_mastering.py + :language: python + :lines: 34-35 +``` +- To update the clustering results, use the function `tc.mastering.update_cluster_results` +```eval_rst +.. literalinclude:: ../../../examples/continuous_mastering.py + :language: python + :lines: 37-38 +``` + +### Publish the clusters +To publish the record clusters, use the function `tc.mastering.publish_clusters` +```eval_rst +.. literalinclude:: ../../../examples/continuous_mastering.py + :language: python + :lines: 40-41 +``` + + +All of the above steps can be combined into the following script `continuous_mastering.py`: + +```eval_rst +.. literalinclude:: ../../../examples/continuous_mastering.py + :language: python +``` +To run the script via command line: +```bash +TAMR_CLIENT_BETA=1 python continuous_mastering.py +``` + +To continue learning, see other tutorials and examples. \ No newline at end of file diff --git a/examples/continuous_mastering.py b/examples/continuous_mastering.py new file mode 100644 index 00000000..b6b42597 --- /dev/null +++ b/examples/continuous_mastering.py @@ -0,0 +1,41 @@ +from getpass import getpass + +import tamr_client as tc + +username = input("Tamr Username:") +password = getpass("Tamr Password:") + +auth = tc.UsernamePasswordAuth(username, password) +session = tc.session.from_auth(auth) + +protocol = "http" +host = "localhost" +port = 9100 + +instance = tc.Instance(protocol=protocol, host=host, port=port) + +project = tc.project.by_name(session, instance, "MasteringTutorial") + +if not isinstance(project, tc.MasteringProject): + raise RuntimeError(f"{project.name} is not a mastering project.") + +operation_1 = tc.mastering.update_unified_dataset(session, project) +tc.operation.check(session, operation_1) + +operation_2 = tc.mastering.generate_pairs(session, project) +tc.operation.check(session, operation_2) + +operation_3 = tc.mastering.apply_feedback(session, project) +tc.operation.check(session, operation_3) + +operation_4 = tc.mastering.update_pair_results(session, project) +tc.operation.check(session, operation_4) + +operation_5 = tc.mastering.update_high_impact_pairs(session, project) +tc.operation.check(session, operation_5) + +operation_6 = tc.mastering.update_cluster_results(session, project) +tc.operation.check(session, operation_6) + +operation_7 = tc.mastering.publish_clusters(session, project) +tc.operation.check(session, operation_7) From 631f171b33fdc8dad77975293c11c2e32add97c9 Mon Sep 17 00:00:00 2001 From: skalish Date: Tue, 27 Oct 2020 15:16:56 -0400 Subject: [PATCH 610/632] Update changelog. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff49509c..b41b69cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - [#456](https://github.com/Datatamer/tamr-client/pull/456) Added first example `tamr_client` script `examples/get_tamr_version.py` - [#461](https://github.com/Datatamer/tamr-client/pull/461) Added functions for golden record workflow operations in `tc.golden_records` - [#462](https://github.com/Datatamer/tamr-client/issues/462) Added function for checking operations, raising exception when operation has failed + - [#469](https://github.com/Datatamer/tamr-client/pull/469) Added "Continuous Mastering" `tamr_client` tutorial **NEW FEATURES** - [#383](https://github.com/Datatamer/tamr-client/issues/383) Now able to create an Operation from Job resource id From 79f36a68f1bdbad7bc026ae345d1be42dc5d286a Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 28 Oct 2020 18:17:20 -0400 Subject: [PATCH 611/632] Update continuous mastering tutorial based on feedback. --- docs/beta/tutorial/continuous_mastering.md | 56 +++++++++++++--------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/docs/beta/tutorial/continuous_mastering.md b/docs/beta/tutorial/continuous_mastering.md index 6e1ee417..2ca7eedd 100644 --- a/docs/beta/tutorial/continuous_mastering.md +++ b/docs/beta/tutorial/continuous_mastering.md @@ -1,5 +1,7 @@ # Tutorial: Continuous Mastering -This tutorial will cover using the Python client to keep a Mastering project up-to-date. This includes carrying new data through to the end of the project and using any new labels to update the machine-learning model. +This tutorial will cover using the Python client to keep a Mastering project up-to-date. This includes carrying new data through to the end of the project and using any new labels to update the machine-learning model. + +While this is intended to propagate changes such as pair labeling that may be applied in the Tamr user interface, at no point during this tutorial is it necessary to interact with the user interface in any way. ## Prerequisites To complete this tutorial you will need: @@ -7,12 +9,15 @@ To complete this tutorial you will need: - access to a Tamr instance, specifically: - a username and password that allow you to log in to Tamr - the socket address of the instance -- an existing Mastering project with loaded data that has been run to the end at least once - - it is recommended that you first complete the tutorial [here](https://docs.tamr.com/tamr-tutorials/docs/overview-mastering) - - alternatively, a different Mastering project can be used however the project name will likely be different +- an existing Mastering project in the following state + - the schema mapping between the attributes of the source datasets and the unified dataset has been defined + - the blocking model has been defined + - labels have been applied to pairs + +It is recommended that you first complete the tutorial [here](https://docs.tamr.com/tamr-tutorials/docs/overview-mastering). Alternatively, a different Mastering project can be used as long as the above conditions are met. ## Steps -### Configure the Session and Instance +### 1. Configure the Session and Instance - Use your username and password to create an instance of `tamr_client.UsernamePasswordAuth`. - Use the function `tamr_client.session.from.auth` to create a `Session`. ```eval_rst @@ -20,15 +25,15 @@ To complete this tutorial you will need: :language: python :lines: 1-9 ``` -- Create an `Instance` using the `protocol`, `host`, and `port` of your Tamr instance. Replace the values of `protocol`, `host`, and `port` with the corresponding values for your Tamr instance. +- Create an `Instance` using the `protocol`, `host`, and `port` of your Tamr instance. Replace these with the corresponding values for your Tamr instance. ```eval_rst .. literalinclude:: ../../../examples/continuous_mastering.py :language: python :lines: 11-15 ``` -### Get the Tamr Mastering project to be updated -Use the function `tc.project.by_name` to retrieve the project information from the server. +### 2. Get the Tamr Mastering project to be updated +Use the function `tc.project.by_name` to retrieve the project information from the server by its name. ```eval_rst .. literalinclude:: ../../../examples/continuous_mastering.py :language: python @@ -41,8 +46,11 @@ Ensure that the retrieved project is a Mastering project by checking its type: :lines: 19-20 ``` -### Update the unified dataset -To apply the [attribute mapping configuration](https://docs.tamr.com/tamr-tutorials/docs/define-project-schema-mastering) and any transformations to update the unified dataset with updated source data, use the function `tc.mastering.update_unified_dataset`. +### 3. Update the unified dataset +To update the unified dataset, use the function `tc.mastering.update_unified_dataset`. This function: +- Applies the [attribute mapping configuration](https://docs.tamr.com/tamr-tutorials/docs/define-project-schema-mastering) +- Applies any transformations +- Updates the unified dataset with updated source data ```eval_rst .. literalinclude:: ../../../examples/continuous_mastering.py :language: python @@ -50,46 +58,51 @@ To apply the [attribute mapping configuration](https://docs.tamr.com/tamr-tutori ``` This function and all others in this tutorial are *synchronous*, meaning that they will not return until the job in Tamr has resolved, either successfully or unsuccessfully. The function `tc.operation.check` will raise an exception and halt the script if the job started in Tamr fails for any reason. -### Generate pairs -To generate pairs according to the [configured pair filter rules](https://docs.tamr.com/tamr-tutorials/docs/setup-how-pairs-are-found), use the function `tc.mastering.generate_pairs` +### 4. Generate pairs +To generate pairs according to the [configured pair filter rules](https://docs.tamr.com/tamr-tutorials/docs/setup-how-pairs-are-found), use the function `tc.mastering.generate_pairs`. ```eval_rst .. literalinclude:: ../../../examples/continuous_mastering.py :language: python :lines: 25-26 ``` -### Train the model with new Labels -To [update the machine-learning model](https://docs.tamr.com/tamr-tutorials/docs/help-tamr-learn-about-your-data) with newly-applied pairs. use the function `tc.mastering.apply_feedback` +### 5. Train the model with new Labels +Running all of the functions in this section and in the "Apply the model" section that follows is equivalent to initiating "Apply feedback and update results" in the Tamr user interface. + +To [update the machine-learning model](https://docs.tamr.com/tamr-tutorials/docs/help-tamr-learn-about-your-data) with newly-applied labels use the function `tc.mastering.apply_feedback`. ```eval_rst .. literalinclude:: ../../../examples/continuous_mastering.py :language: python :lines: 28-29 ``` -Note: The "Apply feedback and update results" action in the Tamr GUI is equivalent to this and the following section. -### Apply the model +### 6. Apply the model +Running all of the functions in the previous "Train the model with new labels" section and in this section is equivalent to initiating "Apply feedback and update results" in the Tamr user interface. + +Running the functions in this section alone is equivalent to initiating "Update results only" in the Tamr user interface. + Applying the trained machine-learning model requires three functions. -- To update the pair prediction results, use the function `tc.mastering.update_pair_results` +- To update the pair prediction results, use the function `tc.mastering.update_pair_results`. ```eval_rst .. literalinclude:: ../../../examples/continuous_mastering.py :language: python :lines: 31-32 ``` -- To update the list of [high-impact pairs](https://docs.tamr.com/tamr-tutorials/docs/help-tamr-learn-about-your-data#4-filter-for-high-impact-pairs), use the function `tc.mastering.update_high_impact_pairs` +- To update the list of [high-impact pairs](https://docs.tamr.com/tamr-tutorials/docs/help-tamr-learn-about-your-data#4-filter-for-high-impact-pairs), use the function `tc.mastering.update_high_impact_pairs`. ```eval_rst .. literalinclude:: ../../../examples/continuous_mastering.py :language: python :lines: 34-35 ``` -- To update the clustering results, use the function `tc.mastering.update_cluster_results` +- To update the clustering results, use the function `tc.mastering.update_cluster_results`. ```eval_rst .. literalinclude:: ../../../examples/continuous_mastering.py :language: python :lines: 37-38 ``` -### Publish the clusters -To publish the record clusters, use the function `tc.mastering.publish_clusters` +### 7. Publish the clusters +To publish the record clusters, use the function `tc.mastering.publish_clusters`. ```eval_rst .. literalinclude:: ../../../examples/continuous_mastering.py :language: python @@ -98,7 +111,6 @@ To publish the record clusters, use the function `tc.mastering.publish_clusters` All of the above steps can be combined into the following script `continuous_mastering.py`: - ```eval_rst .. literalinclude:: ../../../examples/continuous_mastering.py :language: python From 842680e2888ff03f345fc073b3eb1d96cd6bd208 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 4 Nov 2020 13:53:12 -0500 Subject: [PATCH 612/632] version bump to 0.14.0-dev --- CHANGELOG.md | 4 +++- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b41b69cb..be15d2c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ -## 0.13.0-dev +## 0.14.0-dev + +## 0.13.0 **BETA** Important: Do not use BETA features for production workflows. - [#383](https://github.com/Datatamer/tamr-client/issues/383) Added function to get operation from resource ID diff --git a/pyproject.toml b/pyproject.toml index 8d64a4ed..634d402a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "tamr-unify-client" -version = "0.13.0-dev" +version = "0.14.0-dev" description = "Python Client for the Tamr API" license = "Apache-2.0" authors = ["Pedro Cattori "] From adf8a063e0e1fd6a5855771b957d37a49212cf3d Mon Sep 17 00:00:00 2001 From: skalish Date: Tue, 24 Nov 2020 12:19:42 -0500 Subject: [PATCH 613/632] Update checkout and setup-python actions to v2 --- .github/workflows/ci.yml | 22 +++++++++++----------- .github/workflows/release.yml | 4 ++-- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12f59e88..d1965fc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,9 +14,9 @@ jobs: Lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: Install python - uses: actions/setup-python@v1.1.1 + uses: actions/setup-python@v2 with: python-version: 3.6 - name: Install poetry @@ -25,13 +25,13 @@ jobs: run: pip install nox==2020.5.24 - name: Run flake8 run: nox -s lint - + Format: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: Install python - uses: actions/setup-python@v1.1.1 + uses: actions/setup-python@v2 with: python-version: 3.6 - name: Install poetry @@ -44,9 +44,9 @@ jobs: Typecheck: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: Install python - uses: actions/setup-python@v1.1.1 + uses: actions/setup-python@v2 with: python-version: 3.6 - name: Install poetry @@ -62,9 +62,9 @@ jobs: python_version: [3.6, 3.7, 3.8] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: Install python - uses: actions/setup-python@v1.1.1 + uses: actions/setup-python@v2 with: python-version: ${{ matrix.python_version }} - name: Install poetry @@ -77,9 +77,9 @@ jobs: Docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: Install python - uses: actions/setup-python@v1.1.1 + uses: actions/setup-python@v2 with: python-version: 3.6 - name: Install nox diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30c880fb..9141d6a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,9 +8,9 @@ jobs: Publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: Install Python - uses: actions/setup-python@v1.1.1 + uses: actions/setup-python@v2 with: python-version: 3.6 - name: Install Poetry From c41b7d51a1f9f1dcb458bc103405b9a58e740a68 Mon Sep 17 00:00:00 2001 From: Afsana Afzal Date: Thu, 20 Aug 2020 14:14:48 -0400 Subject: [PATCH 614/632] Add functionality to get, initiate and cancel Tamr backups and restores --- CHANGELOG.md | 3 + docs/beta.md | 2 + docs/beta/backup.rst | 17 +++ docs/beta/restore.rst | 16 +++ tamr_client/__init__.py | 4 + tamr_client/_types/__init__.py | 2 + tamr_client/_types/backup.py | 24 ++++ tamr_client/_types/restore.py | 24 ++++ tamr_client/backup.py | 128 ++++++++++++++++++ tamr_client/restore.py | 96 +++++++++++++ .../test_backup/test_by_resource_id.json | 22 +++ .../fake_json/test_backup/test_cancel.json | 22 +++ .../fake_json/test_backup/test_get_all.json | 46 +++++++ .../fake_json/test_backup/test_initiate.json | 22 +++ .../fake_json/test_restore/test_cancel.json | 22 +++ .../fake_json/test_restore/test_get.json | 24 ++++ .../fake_json/test_restore/test_initiate.json | 22 +++ tests/tamr_client/test_backup.py | 48 +++++++ tests/tamr_client/test_restore.py | 27 ++++ 19 files changed, 571 insertions(+) create mode 100644 docs/beta/backup.rst create mode 100644 docs/beta/restore.rst create mode 100644 tamr_client/_types/backup.py create mode 100644 tamr_client/_types/restore.py create mode 100644 tamr_client/backup.py create mode 100644 tamr_client/restore.py create mode 100644 tests/tamr_client/fake_json/test_backup/test_by_resource_id.json create mode 100644 tests/tamr_client/fake_json/test_backup/test_cancel.json create mode 100644 tests/tamr_client/fake_json/test_backup/test_get_all.json create mode 100644 tests/tamr_client/fake_json/test_backup/test_initiate.json create mode 100644 tests/tamr_client/fake_json/test_restore/test_cancel.json create mode 100644 tests/tamr_client/fake_json/test_restore/test_get.json create mode 100644 tests/tamr_client/fake_json/test_restore/test_initiate.json create mode 100644 tests/tamr_client/test_backup.py create mode 100644 tests/tamr_client/test_restore.py diff --git a/CHANGELOG.md b/CHANGELOG.md index be15d2c5..b90fecf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,7 @@ ## 0.14.0-dev + **BETA** + Important: Do not use BETA features for production workflows. + - [#438](https://github.com/Datatamer/tamr-client/pull/438) Now able to get, initiate and cancel Tamr backups and restores ## 0.13.0 **BETA** diff --git a/docs/beta.md b/docs/beta.md index 8ca6dc2f..40d3c0cd 100644 --- a/docs/beta.md +++ b/docs/beta.md @@ -11,6 +11,7 @@ * [Attribute](beta/attribute) * [Auth](beta/auth) + * [Backup](beta/backup) * [Categorization](beta/categorization) * [Dataset](beta/dataset) * [Golden Records](beta/golden_records) @@ -19,6 +20,7 @@ * [Operation](beta/operation) * [Primary Key](beta/primary_key) * [Project](beta/project) + * [Restore](beta/restore) * [Schema Mapping](beta/schema_mapping) * [Transformations](beta/transformations) * [Response](beta/response) diff --git a/docs/beta/backup.rst b/docs/beta/backup.rst new file mode 100644 index 00000000..991c8804 --- /dev/null +++ b/docs/beta/backup.rst @@ -0,0 +1,17 @@ +Backup +====== + +.. autoclass:: tamr_client.Backup + +.. autofunction:: tamr_client.backup.get_all +.. autofunction:: tamr_client.backup.by_resource_id +.. autofunction:: tamr_client.backup.initiate +.. autofunction:: tamr_client.backup.cancel + +Exceptions +---------- + +.. autoclass:: tamr_client.backup.NotFound + :no-inherited-members: +.. autoclass:: tamr_client.backup.InvalidOperation + :no-inherited-members: diff --git a/docs/beta/restore.rst b/docs/beta/restore.rst new file mode 100644 index 00000000..6ea05b18 --- /dev/null +++ b/docs/beta/restore.rst @@ -0,0 +1,16 @@ +Restore +======= + +.. autoclass:: tamr_client.Restore + +.. autofunction:: tamr_client.restore.get +.. autofunction:: tamr_client.restore.initiate +.. autofunction:: tamr_client.restore.cancel + +Exceptions +---------- + +.. autoclass:: tamr_client.restore.NotFound + :no-inherited-members: +.. autoclass:: tamr_client.restore.InvalidOperation + :no-inherited-members: diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 963fb1e8..fc1ee1c1 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -20,6 +20,7 @@ AnyDataset, Attribute, AttributeType, + Backup, CategorizationProject, Dataset, GoldenRecordsProject, @@ -28,6 +29,7 @@ MasteringProject, Operation, Project, + Restore, SchemaMappingProject, Session, SubAttribute, @@ -41,6 +43,7 @@ ############### from tamr_client import attribute +from tamr_client import backup from tamr_client import categorization from tamr_client import dataset from tamr_client import golden_records @@ -50,6 +53,7 @@ from tamr_client import primary_key from tamr_client import project from tamr_client import response +from tamr_client import restore from tamr_client import schema_mapping from tamr_client import session from tamr_client import transformations diff --git a/tamr_client/_types/__init__.py b/tamr_client/_types/__init__.py index e6157bd4..6d52f2fb 100644 --- a/tamr_client/_types/__init__.py +++ b/tamr_client/_types/__init__.py @@ -16,6 +16,7 @@ SubAttribute, ) from tamr_client._types.auth import UsernamePasswordAuth +from tamr_client._types.backup import Backup from tamr_client._types.dataset import AnyDataset, Dataset, UnifiedDataset from tamr_client._types.instance import Instance from tamr_client._types.json import JsonDict @@ -27,6 +28,7 @@ Project, SchemaMappingProject, ) +from tamr_client._types.restore import Restore from tamr_client._types.session import Session from tamr_client._types.transformations import InputTransformation, Transformations from tamr_client._types.url import URL diff --git a/tamr_client/_types/backup.py b/tamr_client/_types/backup.py new file mode 100644 index 00000000..c98d2d78 --- /dev/null +++ b/tamr_client/_types/backup.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass + +from tamr_client._types.url import URL + + +@dataclass(frozen=True) +class Backup: + """A Tamr backup + + See https://docs.tamr.com/new/docs/configuration-backup-and-restore + + Args: + url + resource_id + path + state + error_message + """ + + url: URL + resource_id: str + path: str + state: str + error_message: str diff --git a/tamr_client/_types/restore.py b/tamr_client/_types/restore.py new file mode 100644 index 00000000..6dc07590 --- /dev/null +++ b/tamr_client/_types/restore.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass + +from tamr_client._types.url import URL + + +@dataclass(frozen=True) +class Restore: + """A Tamr restore + + See https://docs.tamr.com/new/docs/configuration-backup-and-restore + + Args: + url + resource_id + backup_path + state + error_message + """ + + url: URL + resource_id: str + backup_path: str + state: str + error_message: str diff --git a/tamr_client/backup.py b/tamr_client/backup.py new file mode 100644 index 00000000..1c672741 --- /dev/null +++ b/tamr_client/backup.py @@ -0,0 +1,128 @@ +from copy import deepcopy +from typing import List + +from tamr_client import Backup, response +from tamr_client._types import Instance, JsonDict, Session, URL +from tamr_client.exception import TamrClientException + + +class InvalidOperation(TamrClientException): + """Raised when attempting an invalid operation. + """ + + pass + + +class NotFound(TamrClientException): + """Raised when referencing a backup that does not exist on the server. + """ + + pass + + +def _from_json(url: URL, data: JsonDict) -> Backup: + """Make backup from JSON data (deserialize). + + Args: + url: Backup URL + data: Backup JSON data from Tamr server + """ + cp = deepcopy(data) + return Backup( + url=url, + resource_id=cp["relativeId"], + path=cp["backupPath"], + state=cp["state"], + error_message=cp["errorMessage"], + ) + + +def get_all(session: Session, instance: Instance) -> List[Backup]: + """Get all backups that have been initiated for a Tamr instance. + + Args: + session: Tamr session + instance: Tamr instance + + Returns: + A list of Tamr backups + + Raises: + backup.NotFound: If no backup found at the specified URL + """ + url = URL(instance=instance, path="backups") + r = session.get(str(url)) + if r.status_code == 404: + raise NotFound(str(url)) + backups = [ + _from_json(URL(instance=instance, path=f'backups/{data["relativeId"]}'), data) + for data in response.successful(r).json() + ] + return backups + + +def by_resource_id(session: Session, instance: Instance, resource_id: str) -> Backup: + """Get information on a specific Tamr backup. + + Args: + session: Tamr session + instance: Tamr instance + resource_id: Resource ID of the backup + + Returns: + A Tamr backup + + Raises: + backup.NotFound: If no backup found at the specified URL + """ + url = URL(instance=instance, path=f"backups/{resource_id}") + r = session.get(str(url)) + if r.status_code == 404: + raise NotFound(str(url)) + return _from_json(url, response.successful(r).json()) + + +def initiate(session: Session, instance: Instance) -> Backup: + """Initiate a Tamr backup. + + Args: + session: Tamr session + instance: Tamr instance + + Returns: + Initiated backup + + Raises: + backup.InvalidOperation: If attempting an invalid operation + """ + url = URL(instance=instance, path="backups") + r = session.post(str(url)) + if r.status_code == 400: + raise InvalidOperation(str(url), r.json()["message"]) + data = response.successful(r).json() + return _from_json( + URL(instance=instance, path=f'backups/{data["relativeId"]}'), data + ) + + +def cancel(session: Session, backup: Backup) -> Backup: + """Cancel a Tamr backup. + + Args: + session: Tamr session + backup: A Tamr backup + + Returns: + Canceled backup + + Raises: + backup.NotFound: If no backup found at the specified URL + backup.InvalidOperation: If attempting an invalid operation + """ + r = session.post(f"{backup.url}:cancel") + if r.status_code == 404: + raise NotFound(f"{backup.url}:cancel") + if r.status_code == 400: + raise InvalidOperation(f"{backup.url}:cancel", r.json()["message"]) + + return _from_json(backup.url, response.successful(r).json()) diff --git a/tamr_client/restore.py b/tamr_client/restore.py new file mode 100644 index 00000000..9cdc520b --- /dev/null +++ b/tamr_client/restore.py @@ -0,0 +1,96 @@ +from tamr_client import response, Restore +from tamr_client._types import Instance, JsonDict, Session, URL +from tamr_client.exception import TamrClientException + + +class InvalidOperation(TamrClientException): + """Raised when attempting an invalid operation. + """ + + pass + + +class NotFound(TamrClientException): + """Raised when referencing a restore that does not exist on the server. + """ + + pass + + +def _from_json(data: JsonDict) -> Restore: + """Make restore from JSON data (deserialize). + + Args: + data: Restore JSON data from Tamr server + """ + return Restore( + url=data["id"], + resource_id=data["relativeId"], + backup_path=data["backupPath"], + state=data["state"], + error_message=data["errorMessage"], + ) + + +def get(session: Session, instance: Instance) -> Restore: + """Get information on the latest Tamr restore, if any. + + Args: + session: Tamr session + instance: Tamr instance + + Returns: + Latest Tamr restore + + Raises: + restore.NotFound: If no backup found at the specified URL + """ + url = URL(instance=instance, path="instance/restore") + r = session.get(str(url)) + if r.status_code == 404: + raise NotFound(str(url)) + return response.successful(r).json() + + +def initiate(session: Session, instance: Instance, backup_path: str) -> Restore: + """Initiate a Tamr restore. + + Args: + session: Tamr session + instance: Tamr instance + backup_path: Path to the backup + + Returns: + Initiated restore + + Raises: + restore.InvalidOperation: If attempting an invalid operation + """ + url = URL(instance=instance, path="instance/restore") + r = session.post(str(url), data=backup_path) + if r.status_code == 400: + raise InvalidOperation(str(url), r.json()["message"]) + return _from_json(response.successful(r).json()) + + +def cancel(session: Session, instance: Instance) -> Restore: + """Cancel a Tamr restore. + + Args: + session: Tamr session + instance: Tamr instance + + Returns: + Canceled restore + + Raises: + restore.NotFound: If no backup file found at the specified path + restore.InvalidOperation: If attempting an invalid operation + """ + url = URL(instance=instance, path="instance/restore:cancel") + r = session.post(str(url)) + if r.status_code == 404: + raise NotFound(str(url)) + if r.status_code == 400: + raise InvalidOperation(str(url), r.json()["message"]) + return _from_json(response.successful(r).json()) diff --git a/tests/tamr_client/fake_json/test_backup/test_by_resource_id.json b/tests/tamr_client/fake_json/test_backup/test_by_resource_id.json new file mode 100644 index 00000000..dba9574f --- /dev/null +++ b/tests/tamr_client/fake_json/test_backup/test_by_resource_id.json @@ -0,0 +1,22 @@ +[ + { + "request": { + "method": "GET", + "path": "backups/2020-08-17_21-32-10-961" + }, + "response": { + "status": 200, + "json": { + "id": "unify://unified-data/v1/backups/2020-08-17_21-32-10-961", + "relativeId": "2020-08-17_21-32-10-961", + "user": "admin", + "backupPath": "/home/ubuntu/tamr/backups/2020-08-17_21-32-10-961", + "state": "RUNNING", + "stage": "", + "errorMessage": "", + "created": "2020-08-17_21-32-10-961", + "lastModified": "2020-08-17_21-51-57-600" + } + } + } +] diff --git a/tests/tamr_client/fake_json/test_backup/test_cancel.json b/tests/tamr_client/fake_json/test_backup/test_cancel.json new file mode 100644 index 00000000..3698ee71 --- /dev/null +++ b/tests/tamr_client/fake_json/test_backup/test_cancel.json @@ -0,0 +1,22 @@ +[ + { + "request": { + "method": "POST", + "path": "backups/2020-08-17_21-32-10-961:cancel" + }, + "response": { + "status": 200, + "json": { + "id": "unify://unified-data/v1/backups/2020-08-17_21-32-10-961", + "relativeId": "2020-08-17_21-32-10-961", + "user": "admin", + "backupPath": "/home/ubuntu/tamr/backups/2020-08-17_21-32-10-961", + "state": "CANCELED", + "stage": "", + "errorMessage": "", + "created": "2020-08-17_21-32-10-961", + "lastModified": "2020-08-17_21-51-57-600" + } + } + } +] diff --git a/tests/tamr_client/fake_json/test_backup/test_get_all.json b/tests/tamr_client/fake_json/test_backup/test_get_all.json new file mode 100644 index 00000000..945a0f26 --- /dev/null +++ b/tests/tamr_client/fake_json/test_backup/test_get_all.json @@ -0,0 +1,46 @@ +[ + { + "request": { + "method": "GET", + "path": "backups" + }, + "response": { + "status": 200, + "json": [ + { + "id": "unify://unified-data/v1/backups/2020-08-17_21-32-10-961", + "relativeId": "2020-08-17_21-32-10-961", + "user": "admin", + "backupPath": "/home/ubuntu/tamr/backups/2020-08-17_21-32-10-961", + "state": "CANCELED", + "stage": "", + "errorMessage": "", + "created": "2020-08-17_21-32-10-961", + "lastModified": "2020-08-17_21-51-57-600" + }, + { + "id": "unify://unified-data/v1/backups/2020-08-17_21-58-01-205", + "relativeId": "2020-08-17_21-58-01-205", + "user": "admin", + "backupPath": "/home/ubuntu/tamr/backups/2020-08-17_21-58-01-205", + "state": "RUNNING", + "stage": "", + "errorMessage": "", + "created": "2020-08-17_21-58-01-205", + "lastModified": "2020-08-17_21-58-01-351" + }, + { + "id": "unify://unified-data/v1/backups/2020-08-17_21-58-45-062", + "relativeId": "2020-08-17_21-58-45-062", + "user": "admin", + "backupPath": "/home/ubuntu/tamr/backups/2020-08-17_21-58-45-062", + "state": "FAILED", + "stage": "", + "errorMessage": "A system operation is already in progress", + "created": "2020-08-17_21-58-45-062", + "lastModified": "2020-08-17_21-58-45-249" + } + ] + } + } +] diff --git a/tests/tamr_client/fake_json/test_backup/test_initiate.json b/tests/tamr_client/fake_json/test_backup/test_initiate.json new file mode 100644 index 00000000..98157eee --- /dev/null +++ b/tests/tamr_client/fake_json/test_backup/test_initiate.json @@ -0,0 +1,22 @@ +[ + { + "request": { + "method": "POST", + "path": "backups" + }, + "response": { + "status": 200, + "json": { + "id": "unify://unified-data/v1/backups/2020-08-17_21-32-10-961", + "relativeId": "2020-08-17_21-32-10-961", + "user": "admin", + "backupPath": "/home/ubuntu/tamr/backups/2020-08-17_21-32-10-961", + "state": "PENDING", + "stage": "", + "errorMessage": "", + "created": "2020-08-17_21-32-10-961", + "lastModified": "2020-08-17_21-32-10-961" + } + } + } +] diff --git a/tests/tamr_client/fake_json/test_restore/test_cancel.json b/tests/tamr_client/fake_json/test_restore/test_cancel.json new file mode 100644 index 00000000..1cfc37a2 --- /dev/null +++ b/tests/tamr_client/fake_json/test_restore/test_cancel.json @@ -0,0 +1,22 @@ +[ + { + "request": { + "method": "POST", + "path": "instance/restore:cancel" + }, + "response": { + "status": 200, + "json": { + "id": "unify://unified-data/v1/restore/restore-2020-08-19_20-01-20-233", + "relativeId": "restore-2020-08-19_20-01-20-233", + "user": "admin", + "backupPath": "/home/ubuntu/tamr/backups/2020-08-17_22-07-11-100", + "state": "CANCELED", + "stage": "", + "errorMessage": "", + "created": "2020-08-19_20-01-20-233", + "lastModified": "2020-08-19_20-02-19-351" + } + } + } +] diff --git a/tests/tamr_client/fake_json/test_restore/test_get.json b/tests/tamr_client/fake_json/test_restore/test_get.json new file mode 100644 index 00000000..8eb2d088 --- /dev/null +++ b/tests/tamr_client/fake_json/test_restore/test_get.json @@ -0,0 +1,24 @@ +[ + { + "request": { + "method": "GET", + "path": "instance/restore" + }, + "response": { + "status": 200, + "json": [ + { + "id": "unify://unified-data/v1/restore/restore-2020-08-19_19-57-57-366", + "relativeId": "restore-2020-08-19_19-57-57-366", + "user": "admin", + "backupPath": "/home/ubuntu/tamr/backups/2020-08-17_22-07-11-100", + "state": "RUNNING", + "stage": "", + "errorMessage": "", + "created": "2020-08-19_19-57-57-366", + "lastModified": "2020-08-19_19-57-57-508" + } + ] + } + } +] diff --git a/tests/tamr_client/fake_json/test_restore/test_initiate.json b/tests/tamr_client/fake_json/test_restore/test_initiate.json new file mode 100644 index 00000000..6699a3ac --- /dev/null +++ b/tests/tamr_client/fake_json/test_restore/test_initiate.json @@ -0,0 +1,22 @@ +[ + { + "request": { + "method": "POST", + "path": "instance/restore" + }, + "response": { + "status": 200, + "json": { + "id": "unify://unified-data/v1/restore/restore-2020-08-19_20-01-20-233", + "relativeId": "restore-2020-08-19_20-01-20-233", + "user": "admin", + "backupPath": "/home/ubuntu/tamr/backups/2020-08-17_22-07-11-100", + "state": "PENDING", + "stage": "", + "errorMessage": "", + "created": "2020-08-19_20-01-20-233", + "lastModified": "2020-08-19_20-01-20-233" + } + } + } +] diff --git a/tests/tamr_client/test_backup.py b/tests/tamr_client/test_backup.py new file mode 100644 index 00000000..3ab6569f --- /dev/null +++ b/tests/tamr_client/test_backup.py @@ -0,0 +1,48 @@ +import tamr_client as tc +from tests.tamr_client import fake + + +@fake.json +def test_get_all(): + s = fake.session() + instance = fake.instance() + + tc.backup.get_all(session=s, instance=instance) + + +@fake.json +def test_by_resource_id(): + s = fake.session() + instance = fake.instance() + resource_id = "2020-08-17_21-32-10-961" + + tc.backup.by_resource_id(session=s, instance=instance, resource_id=resource_id) + + +@fake.json +def test_initiate(): + s = fake.session() + instance = fake.instance() + + tc.backup.initiate(session=s, instance=instance) + + +@fake.json +def test_cancel(): + s = fake.session() + data = { + "id": "unify://unified-data/v1/backups/2020-08-17_21-32-10-961", + "relativeId": "2020-08-17_21-32-10-961", + "user": "admin", + "backupPath": "/home/ubuntu/tamr/backups/2020-08-17_21-32-10-961", + "state": "CANCELED", + "stage": "", + "errorMessage": "", + "created": "2020-08-17_21-32-10-961", + "lastModified": "2020-08-17_21-51-57-600", + } + backup = tc.backup._from_json( + url=tc.URL(path="backups/2020-08-17_21-32-10-961"), data=data + ) + + tc.backup.cancel(session=s, backup=backup) diff --git a/tests/tamr_client/test_restore.py b/tests/tamr_client/test_restore.py new file mode 100644 index 00000000..6388e405 --- /dev/null +++ b/tests/tamr_client/test_restore.py @@ -0,0 +1,27 @@ +import tamr_client as tc +from tests.tamr_client import fake + + +@fake.json +def test_get(): + s = fake.session() + instance = fake.instance() + + tc.restore.get(session=s, instance=instance) + + +@fake.json +def test_initiate(): + s = fake.session() + instance = fake.instance() + backup_path = "2020-08-19_20-01-20-233" + + tc.restore.initiate(session=s, instance=instance, backup_path=backup_path) + + +@fake.json +def test_cancel(): + s = fake.session() + instance = fake.instance() + + tc.restore.cancel(session=s, instance=instance) From af6dc6e50432713a3acf7793ec9da4be0763e45e Mon Sep 17 00:00:00 2001 From: skalish Date: Tue, 24 Nov 2020 18:54:05 -0500 Subject: [PATCH 615/632] Change implementation of Tamr restore functionality --- tamr_client/restore.py | 22 +++++++++---------- .../fake_json/test_restore/test_get.json | 6 ++--- tests/tamr_client/test_restore.py | 17 +++++++++++--- 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/tamr_client/restore.py b/tamr_client/restore.py index 9cdc520b..b33fb8c8 100644 --- a/tamr_client/restore.py +++ b/tamr_client/restore.py @@ -17,14 +17,15 @@ class NotFound(TamrClientException): pass -def _from_json(data: JsonDict) -> Restore: +def _from_json(url: URL, data: JsonDict) -> Restore: """Make restore from JSON data (deserialize). Args: + url: Restore url data: Restore JSON data from Tamr server """ return Restore( - url=data["id"], + url=url, resource_id=data["relativeId"], backup_path=data["backupPath"], state=data["state"], @@ -49,7 +50,7 @@ def get(session: Session, instance: Instance) -> Restore: r = session.get(str(url)) if r.status_code == 404: raise NotFound(str(url)) - return response.successful(r).json() + return _from_json(url, response.successful(r).json()) def initiate(session: Session, instance: Instance, backup_path: str) -> Restore: @@ -70,15 +71,15 @@ def initiate(session: Session, instance: Instance, backup_path: str) -> Restore: r = session.post(str(url), data=backup_path) if r.status_code == 400: raise InvalidOperation(str(url), r.json()["message"]) - return _from_json(response.successful(r).json()) + return _from_json(url, response.successful(r).json()) -def cancel(session: Session, instance: Instance) -> Restore: +def cancel(session: Session, restore: Restore) -> Restore: """Cancel a Tamr restore. Args: session: Tamr session - instance: Tamr instance + restore: A Tamr restore Returns: Canceled restore @@ -87,10 +88,9 @@ def cancel(session: Session, instance: Instance) -> Restore: restore.NotFound: If no backup file found at the specified path restore.InvalidOperation: If attempting an invalid operation """ - url = URL(instance=instance, path="instance/restore:cancel") - r = session.post(str(url)) + r = session.post(f"{restore.url}:cancel") if r.status_code == 404: - raise NotFound(str(url)) + raise NotFound(str(restore.url)) if r.status_code == 400: - raise InvalidOperation(str(url), r.json()["message"]) - return _from_json(response.successful(r).json()) + raise InvalidOperation(str(restore.url), r.json()["message"]) + return _from_json(restore.url, response.successful(r).json()) diff --git a/tests/tamr_client/fake_json/test_restore/test_get.json b/tests/tamr_client/fake_json/test_restore/test_get.json index 8eb2d088..1207c83c 100644 --- a/tests/tamr_client/fake_json/test_restore/test_get.json +++ b/tests/tamr_client/fake_json/test_restore/test_get.json @@ -6,8 +6,7 @@ }, "response": { "status": 200, - "json": [ - { + "json": { "id": "unify://unified-data/v1/restore/restore-2020-08-19_19-57-57-366", "relativeId": "restore-2020-08-19_19-57-57-366", "user": "admin", @@ -17,8 +16,7 @@ "errorMessage": "", "created": "2020-08-19_19-57-57-366", "lastModified": "2020-08-19_19-57-57-508" - } - ] + } } } ] diff --git a/tests/tamr_client/test_restore.py b/tests/tamr_client/test_restore.py index 6388e405..8a91f0f3 100644 --- a/tests/tamr_client/test_restore.py +++ b/tests/tamr_client/test_restore.py @@ -22,6 +22,17 @@ def test_initiate(): @fake.json def test_cancel(): s = fake.session() - instance = fake.instance() - - tc.restore.cancel(session=s, instance=instance) + data = { + "id": "unify://unified-data/v1/restore/restore-2020-08-19_20-01-20-233", + "relativeId": "restore-2020-08-19_20-01-20-233", + "user": "admin", + "backupPath": "/home/ubuntu/tamr/backups/2020-08-17_22-07-11-100", + "state": "CANCELED", + "stage": "", + "errorMessage": "", + "created": "2020-08-19_20-01-20-233", + "lastModified": "2020-08-19_20-02-19-351", + } + restore = tc.restore._from_json(url=tc.URL(path="instance/restore"), data=data) + + tc.restore.cancel(session=s, restore=restore) From 20dcc8f31691fba85c95bdcecf78ff59ee70c6f3 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 2 Dec 2020 10:41:21 -0500 Subject: [PATCH 616/632] Remove redundant resource_id attribute of Backup and Restore dataclasses. --- tamr_client/_types/backup.py | 2 -- tamr_client/_types/restore.py | 2 -- tamr_client/backup.py | 9 ++++----- tamr_client/restore.py | 8 ++++---- tests/tamr_client/test_backup.py | 2 +- tests/tamr_client/test_restore.py | 2 +- 6 files changed, 10 insertions(+), 15 deletions(-) diff --git a/tamr_client/_types/backup.py b/tamr_client/_types/backup.py index c98d2d78..98912b2a 100644 --- a/tamr_client/_types/backup.py +++ b/tamr_client/_types/backup.py @@ -11,14 +11,12 @@ class Backup: Args: url - resource_id path state error_message """ url: URL - resource_id: str path: str state: str error_message: str diff --git a/tamr_client/_types/restore.py b/tamr_client/_types/restore.py index 6dc07590..45031f04 100644 --- a/tamr_client/_types/restore.py +++ b/tamr_client/_types/restore.py @@ -11,14 +11,12 @@ class Restore: Args: url - resource_id backup_path state error_message """ url: URL - resource_id: str backup_path: str state: str error_message: str diff --git a/tamr_client/backup.py b/tamr_client/backup.py index 1c672741..b6587167 100644 --- a/tamr_client/backup.py +++ b/tamr_client/backup.py @@ -30,7 +30,6 @@ def _from_json(url: URL, data: JsonDict) -> Backup: cp = deepcopy(data) return Backup( url=url, - resource_id=cp["relativeId"], path=cp["backupPath"], state=cp["state"], error_message=cp["errorMessage"], @@ -119,10 +118,10 @@ def cancel(session: Session, backup: Backup) -> Backup: backup.NotFound: If no backup found at the specified URL backup.InvalidOperation: If attempting an invalid operation """ - r = session.post(f"{backup.url}:cancel") + cancel_url = f"{backup.url}:cancel" + r = session.post(cancel_url) if r.status_code == 404: - raise NotFound(f"{backup.url}:cancel") + raise NotFound(cancel_url) if r.status_code == 400: - raise InvalidOperation(f"{backup.url}:cancel", r.json()["message"]) - + raise InvalidOperation(cancel_url, r.json()["message"]) return _from_json(backup.url, response.successful(r).json()) diff --git a/tamr_client/restore.py b/tamr_client/restore.py index b33fb8c8..9eaebb29 100644 --- a/tamr_client/restore.py +++ b/tamr_client/restore.py @@ -26,7 +26,6 @@ def _from_json(url: URL, data: JsonDict) -> Restore: """ return Restore( url=url, - resource_id=data["relativeId"], backup_path=data["backupPath"], state=data["state"], error_message=data["errorMessage"], @@ -88,9 +87,10 @@ def cancel(session: Session, restore: Restore) -> Restore: restore.NotFound: If no backup file found at the specified path restore.InvalidOperation: If attempting an invalid operation """ - r = session.post(f"{restore.url}:cancel") + cancel_url = f"{restore.url}:cancel" + r = session.post(cancel_url) if r.status_code == 404: - raise NotFound(str(restore.url)) + raise NotFound(cancel_url) if r.status_code == 400: - raise InvalidOperation(str(restore.url), r.json()["message"]) + raise InvalidOperation(cancel_url, r.json()["message"]) return _from_json(restore.url, response.successful(r).json()) diff --git a/tests/tamr_client/test_backup.py b/tests/tamr_client/test_backup.py index 3ab6569f..3f27b5cf 100644 --- a/tests/tamr_client/test_backup.py +++ b/tests/tamr_client/test_backup.py @@ -35,7 +35,7 @@ def test_cancel(): "relativeId": "2020-08-17_21-32-10-961", "user": "admin", "backupPath": "/home/ubuntu/tamr/backups/2020-08-17_21-32-10-961", - "state": "CANCELED", + "state": "RUNNING", "stage": "", "errorMessage": "", "created": "2020-08-17_21-32-10-961", diff --git a/tests/tamr_client/test_restore.py b/tests/tamr_client/test_restore.py index 8a91f0f3..24564b46 100644 --- a/tests/tamr_client/test_restore.py +++ b/tests/tamr_client/test_restore.py @@ -27,7 +27,7 @@ def test_cancel(): "relativeId": "restore-2020-08-19_20-01-20-233", "user": "admin", "backupPath": "/home/ubuntu/tamr/backups/2020-08-17_22-07-11-100", - "state": "CANCELED", + "state": "RUNNING", "stage": "", "errorMessage": "", "created": "2020-08-19_20-01-20-233", From 7ac26017c0096953018a01fe223a2e73a1b8bad5 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 2 Dec 2020 11:31:52 -0500 Subject: [PATCH 617/632] Add backup.poll function. --- docs/beta/backup.rst | 1 + tamr_client/backup.py | 23 +++++++++++++++++++ .../fake_json/test_backup/test_poll.json | 22 ++++++++++++++++++ tests/tamr_client/test_backup.py | 21 +++++++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 tests/tamr_client/fake_json/test_backup/test_poll.json diff --git a/docs/beta/backup.rst b/docs/beta/backup.rst index 991c8804..9c849592 100644 --- a/docs/beta/backup.rst +++ b/docs/beta/backup.rst @@ -7,6 +7,7 @@ Backup .. autofunction:: tamr_client.backup.by_resource_id .. autofunction:: tamr_client.backup.initiate .. autofunction:: tamr_client.backup.cancel +.. autofunction:: tamr_client.backup.poll Exceptions ---------- diff --git a/tamr_client/backup.py b/tamr_client/backup.py index b6587167..2938f6bc 100644 --- a/tamr_client/backup.py +++ b/tamr_client/backup.py @@ -125,3 +125,26 @@ def cancel(session: Session, backup: Backup) -> Backup: if r.status_code == 400: raise InvalidOperation(cancel_url, r.json()["message"]) return _from_json(backup.url, response.successful(r).json()) + + +def poll(session: Session, backup: Backup) -> Backup: + """Poll this backup for server-side updates. + + Does not update the :class:`~tamr_client.backup.Backup` object. + Instead, returns a new :class:`~tamr_client.backup.Backup`. + + Args: + session: Tamr session + backup: Tamr backup to be polled + + Returns: + A Tamr backup + + Raises: + backup.NotFound: If no backup found at the specified URL + """ + url = backup.url + r = session.get(str(url)) + if r.status_code == 404: + raise NotFound(str(url)) + return _from_json(url, response.successful(r).json()) diff --git a/tests/tamr_client/fake_json/test_backup/test_poll.json b/tests/tamr_client/fake_json/test_backup/test_poll.json new file mode 100644 index 00000000..dba9574f --- /dev/null +++ b/tests/tamr_client/fake_json/test_backup/test_poll.json @@ -0,0 +1,22 @@ +[ + { + "request": { + "method": "GET", + "path": "backups/2020-08-17_21-32-10-961" + }, + "response": { + "status": 200, + "json": { + "id": "unify://unified-data/v1/backups/2020-08-17_21-32-10-961", + "relativeId": "2020-08-17_21-32-10-961", + "user": "admin", + "backupPath": "/home/ubuntu/tamr/backups/2020-08-17_21-32-10-961", + "state": "RUNNING", + "stage": "", + "errorMessage": "", + "created": "2020-08-17_21-32-10-961", + "lastModified": "2020-08-17_21-51-57-600" + } + } + } +] diff --git a/tests/tamr_client/test_backup.py b/tests/tamr_client/test_backup.py index 3f27b5cf..20528950 100644 --- a/tests/tamr_client/test_backup.py +++ b/tests/tamr_client/test_backup.py @@ -46,3 +46,24 @@ def test_cancel(): ) tc.backup.cancel(session=s, backup=backup) + + +@fake.json +def test_poll(): + s = fake.session() + data = { + "id": "unify://unified-data/v1/backups/2020-08-17_21-32-10-961", + "relativeId": "2020-08-17_21-32-10-961", + "user": "admin", + "backupPath": "/home/ubuntu/tamr/backups/2020-08-17_21-32-10-961", + "state": "RUNNING", + "stage": "", + "errorMessage": "", + "created": "2020-08-17_21-32-10-961", + "lastModified": "2020-08-17_21-51-57-600", + } + backup = tc.backup._from_json( + url=tc.URL(path="backups/2020-08-17_21-32-10-961"), data=data + ) + + tc.backup.poll(session=s, backup=backup) From 76e685fe695d91e8d4705c324759cc2e365d215d Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 6 Jan 2021 14:10:17 -0500 Subject: [PATCH 618/632] style(ci): format yaml indentation --- .github/workflows/ci.yml | 106 +++++++++++++++++----------------- .github/workflows/release.yml | 26 ++++----- 2 files changed, 66 insertions(+), 66 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1965fc3..b9f78e9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,47 +14,47 @@ jobs: Lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Install python - uses: actions/setup-python@v2 - with: - python-version: 3.6 - - name: Install poetry - run: pip install poetry==1.0.5 - - name: Install nox - run: pip install nox==2020.5.24 - - name: Run flake8 - run: nox -s lint + - uses: actions/checkout@v2 + - name: Install python + uses: actions/setup-python@v2 + with: + python-version: 3.6 + - name: Install poetry + run: pip install poetry==1.0.5 + - name: Install nox + run: pip install nox==2020.5.24 + - name: Run flake8 + run: nox -s lint Format: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Install python - uses: actions/setup-python@v2 - with: - python-version: 3.6 - - name: Install poetry - run: pip install poetry==1.0.5 - - name: Install nox - run: pip install nox==2020.5.24 - - name: Run black - run: nox -s format + - uses: actions/checkout@v2 + - name: Install python + uses: actions/setup-python@v2 + with: + python-version: 3.6 + - name: Install poetry + run: pip install poetry==1.0.5 + - name: Install nox + run: pip install nox==2020.5.24 + - name: Run black + run: nox -s format Typecheck: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Install python - uses: actions/setup-python@v2 - with: - python-version: 3.6 - - name: Install poetry - run: pip install poetry==1.0.5 - - name: Install nox - run: pip install nox==2020.5.24 - - name: Run mypy - run: nox -s typecheck + - uses: actions/checkout@v2 + - name: Install python + uses: actions/setup-python@v2 + with: + python-version: 3.6 + - name: Install poetry + run: pip install poetry==1.0.5 + - name: Install nox + run: pip install nox==2020.5.24 + - name: Run mypy + run: nox -s typecheck Test: strategy: @@ -62,27 +62,27 @@ jobs: python_version: [3.6, 3.7, 3.8] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Install python - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python_version }} - - name: Install poetry - run: pip install poetry==1.0.5 - - name: Install nox - run: pip install nox==2020.5.24 - - name: Run pytest - run: nox -s test-${{ matrix.python_version }} + - uses: actions/checkout@v2 + - name: Install python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python_version }} + - name: Install poetry + run: pip install poetry==1.0.5 + - name: Install nox + run: pip install nox==2020.5.24 + - name: Run pytest + run: nox -s test-${{ matrix.python_version }} Docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Install python - uses: actions/setup-python@v2 - with: - python-version: 3.6 - - name: Install nox - run: pip install nox==2020.5.24 - - name: Run sphinx-build - run: nox -s docs \ No newline at end of file + - uses: actions/checkout@v2 + - name: Install python + uses: actions/setup-python@v2 + with: + python-version: 3.6 + - name: Install nox + run: pip install nox==2020.5.24 + - name: Run sphinx-build + run: nox -s docs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9141d6a5..329032a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,16 +8,16 @@ jobs: Publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Install Python - uses: actions/setup-python@v2 - with: - python-version: 3.6 - - name: Install Poetry - uses: dschep/install-poetry-action@v1.2 - - name: Install dependencies - run: poetry install --no-dev - - name: Publish to PyPI - env: - POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }} - run: poetry publish --build + - uses: actions/checkout@v2 + - name: Install Python + uses: actions/setup-python@v2 + with: + python-version: 3.6 + - name: Install Poetry + uses: dschep/install-poetry-action@v1.2 + - name: Install dependencies + run: poetry install --no-dev + - name: Publish to PyPI + env: + POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }} + run: poetry publish --build From 3f4b33b1d8ce232806d28648b5e818661107e54b Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 6 Jan 2021 14:17:43 -0500 Subject: [PATCH 619/632] ci(commitlint): enforce conventional commits See https://www.conventionalcommits.org/en/v1.0.0/ . Note that `fetch-depth: 0` is necessary for `actions/checkout@v2` to checkout the entire git history, and not just the last commit. --- .github/workflows/ci.yml | 15 +++++++++++++++ commitlint.config.js | 1 + 2 files changed, 16 insertions(+) create mode 100644 commitlint.config.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9f78e9d..b6d6c17b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,3 +86,18 @@ jobs: run: pip install nox==2020.5.24 - name: Run sphinx-build run: nox -s docs + + Commitlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Install npm + uses: actions/setup-node@v2 + with: + node-version: "14" + - name: Install commitlint + run: npm install -g @commitlint/cli @commitlint/config-conventional + - name: Run commitlint + run: commitlint --from=origin/master diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 00000000..858aaa8c --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1 @@ +module.exports = { extends: ["@commitlint/config-conventional"] } From c7a331a5a6dea1c74b167a2201e8c9e25dba7f47 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 7 Jan 2021 09:25:39 -0500 Subject: [PATCH 620/632] ci(release): automate releases via semantic-release See https://semantic-release.gitbook.io/semantic-release/ --- .github/workflows/release.yml | 24 ++++++++----- .releaserc.yaml | 16 +++++++++ RELEASE.md | 68 ----------------------------------- pyproject.toml | 2 +- 4 files changed, 33 insertions(+), 77 deletions(-) create mode 100644 .releaserc.yaml delete mode 100644 RELEASE.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 329032a8..85393d39 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,9 @@ name: Release on: - release: - types: [published] + push: + branches: + - master jobs: Publish: @@ -13,11 +14,18 @@ jobs: uses: actions/setup-python@v2 with: python-version: 3.6 - - name: Install Poetry - uses: dschep/install-poetry-action@v1.2 - - name: Install dependencies - run: poetry install --no-dev - - name: Publish to PyPI + - name: Install poetry and toml-cli + run: | + pip install --upgrade pip + pip install poetry==1.1.4 + pip install toml-cli==0.1.3 + - name: Install npm + uses: actions/setup-node@v2 + with: + node-version: "14" + - name: Install semantic-release + run: npm install -g semantic-release@17 @semantic-release/exec@5 + - name: Run semantic-release env: POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }} - run: poetry publish --build + run: semantic-release diff --git a/.releaserc.yaml b/.releaserc.yaml new file mode 100644 index 00000000..a7ca97e8 --- /dev/null +++ b/.releaserc.yaml @@ -0,0 +1,16 @@ +repositoryUrl: https://github.com/Datatamer/tamr-client +branches: + - master +plugins: + - "@semantic-release/commit-analyzer" + - "@semantic-release/release-notes-generator" + - [ + "@semantic-release/exec", + { + # Set the project version according to semantic-release (depends on `toml-cli`) + prepareCmd: "toml set --toml-path pyproject.toml tool.poetry.version ${nextRelease.version}", + # Publish the project to PyPI (depends on `python` and `poetry`) + publishCmd: "poetry install --no-dev && poetry publish --build", + }, + ] + - "@semantic-release/github" diff --git a/RELEASE.md b/RELEASE.md deleted file mode 100644 index 0fa661a2..00000000 --- a/RELEASE.md +++ /dev/null @@ -1,68 +0,0 @@ -# Release process for `tamr-unify-client` - -During the following steps, we'll consider releasing the `0.3.0` version as an example. - -For our example, that means `master` branch is currently on version `0.3.0-dev` (you can check the actual version in `pyproject.toml`). - -Be sure to substitute `0.3.0` appropriately with the actual version being released. - -NOTE: You should make sure the commit you plan to use for the release branch is passing CI tests. - -# 1. Version bump on `master` - -Create a PR with the following changes: -- `pyproject.toml`: bump the version to the next one, keeping the `-dev` suffix e.g. `0.3.0-dev` -> `0.4.0-dev`. -- `CHANGELOG.md`: - - Create a new section for the new development version e.g. Add `# 0.4.0-dev` to the top of the changelog (with an empty line between it and the next version header). - - Remove the `-dev` suffix from the version being released e.g. `# 0.3.0-dev` -> `# 0.3.0`. - -Ensure CI tests pass for your PR and merge your changes into `master`. - -# 2. Cut a release branch - -On the [Datatamer/tamr-client](https://github.com/Datatamer/tamr-client) Github repo, click on [Commits](https://github.com/Datatamer/tamr-client/commits/master). Navigate to the commit just before the version bump commit from Step 1. Click the `<>` icon to browse the repo at that commit. - -Then, create a branch on Github within the [Datatamer/tamr-client](https://github.com/Datatamer/tamr-client) repo titled `release-` e.g. `release-0.3.0`. - -Create a branch locally with the following commands: -1. `git fetch Datatamer` (this will pull down the release branch you created on Github) -2. `git checkout Datatamer/release-0.3.0` (this will get you on the release branch) -3. `git checkout -b release-0.3.0` (creating a branch for you to make the ensuing edits in step 3) - -NOTE: This release branch should *not* contain the version bump changes from Step 1. - -# 3. Remove `-dev` suffix on release branch - -Create a PR *to the release branch* (`Datatamer/release-0.3.0`) *from your release branch* (`my-github-username/release-0.3.0`)with the following changes: -- `pyproject.toml`: Remove `-dev` suffix from version e.g. `0.3.0-dev` -> `0.3.0`. -- `CHANGELOG.md`: Remove the `-dev` suffix from the version being released e.g. `# 0.3.0-dev` -> `# 0.3.0`. - -Ensure CI tests pass for your PR and merge your changes into the release branch e.g. `release-0.3.0`. - -# 4. Create a Github release - -On the [Datatamer/tamr-client](https://github.com/Datatamer/tamr-client) Github repo, click on [Releases](https://github.com/Datatamer/tamr-client/releases). Click "Draft a new release". - -Title the release with the release version. Do not include anything else in the release title e.g. -- Correct: `0.3.0` -- Incorrect: `v0.3.0` -- Incorrect: `Release 0.3.0` - -**Select the corresponding release branch in the** `Target` **branch dropdown.** - -Copy/paste the `CHANGELOG.md` entries for this release into the description for the release (only the entries, not the header since the version number is already encoded as the title for this release). - -Create the release. This should also implicitly create a tag for the release under [Tags](https://github.com/Datatamer/tamr-client/tags). - -# 5. Check on published artifacts - -CI is wired to [publish releases to PyPI for any published Github releases](https://github.com/Datatamer/tamr-client/blob/master/.github/workflows/release.yml). - -Check that CI successfully published the release version to [PyPI](https://pypi.org/project/tamr-unify-client/#history). - -On the `master` branch, add release date for this release in the `CHANGELOG.md`. ---- - -If everything went correctly `pip install -U tamr-unify-client` should install the new release of the Python Client. - -If testing/publishing failed on the release branch, make additional PRs to fix any issues and get CI tests to pass. Be sure to merge those fixes into `master` too! diff --git a/pyproject.toml b/pyproject.toml index 634d402a..74879166 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "tamr-unify-client" -version = "0.14.0-dev" +version = "0.0.0" description = "Python Client for the Tamr API" license = "Apache-2.0" authors = ["Pedro Cattori "] From c95629126fe62d7c4799f03dde8f4873bd69d779 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 7 Jan 2021 09:54:56 -0500 Subject: [PATCH 621/632] docs(changelog): remove redundant changelog Changelog is obsoleted by automated Github release notes. Also, remove references to the changelog. Also, simplify the Pull Request template. Fixes #387 --- .github/PULL_REQUEST_TEMPLATE.md | 15 +- CHANGELOG.md | 304 ------------------------------- docs/user-guide/faq.md | 2 +- 3 files changed, 4 insertions(+), 317 deletions(-) delete mode 100644 CHANGELOG.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 647c08b8..e62bc81d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,9 +1,3 @@ - - # ↪️ Pull Request + ## ✔️ PR Todo -- [ ] Added/updated testing for this change -- [ ] Included links to related issues/PRs +- [ ] Testing for this change +- [ ] Links to related issues/PRs - [ ] Update relevant [docs](https://github.com/Datatamer/tamr-client/tree/master/docs) + docstrings -- [ ] Update the [CHANGELOG](https://github.com/Datatamer/tamr-client/blob/master/CHANGELOG.md) under the current `-dev` version: - - Add changelog entries under any that apply: **BREAKING CHANGES**, **NEW FEATURES**, **BUG FIXES**. - - Changelog entry format: `[#]() ` diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index b90fecf9..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,304 +0,0 @@ -## 0.14.0-dev - **BETA** - Important: Do not use BETA features for production workflows. - - [#438](https://github.com/Datatamer/tamr-client/pull/438) Now able to get, initiate and cancel Tamr backups and restores - -## 0.13.0 - **BETA** - Important: Do not use BETA features for production workflows. - - [#383](https://github.com/Datatamer/tamr-client/issues/383) Added function to get operation from resource ID - - [#421](https://github.com/Datatamer/tamr-client/pull/421) Added functions for getting and replacing the transformations of a projects via `tc.transformations.get_all()` and `tc.transformations.replace_all()` - - Added new dataclasses `Transformations` and `InputTransformations` to support these functions - - [#425](https://github.com/Datatamer/tamr-client/pull/425) Now able to get, update and delete manual labels for Categorization projects - - [#428](https://github.com/Datatamer/tamr-client/pull/428) Moved function `tc.attribute.from_dataset_all` to `tc.dataset.attributes` - - [#434](https://github.com/Datatamer/tamr-client/pull/434) Added `tc.instance.version` function to get Tamr Version - - [#435](https://github.com/Datatamer/tamr-client/pull/435) Now able to create projects of the following type in Tamr: Categorization, Mastering, Schema Mapping - - [#440](https://github.com/Datatamer/tamr-client/pull/440) Added functions for initiating basic mastering workflow operations in `tc.mastering` - - [#443](https://github.com/Datatamer/tamr-client/pull/443) Added function to materialize datasets. - - [#445](https://github.com/Datatamer/tamr-client/pull/445) Added functions for getting projects and datasets by name via `tc.project.by_name` and `tc.dataset.by_name` - - Renamed functions `from_resource_id` to `by_resource_id` in `tc.attribute`, `tc.dataset`, `tc.operation`, and `tc.project` - - [#446](https://github.com/Datatamer/tamr-client/pull/446) Added functions for categorization workflow operations in `tc.categorization` and schema mapping workflow operations in `tc.schema_mapping` - - [#452](https://github.com/Datatamer/tamr-client/pull/452) Added functions for creating and deleting a dataset via `tc.dataset.create` and `tc.dataset.delete` - - Added function for deleting all records in a dataset via `tc.record.delete_all` - - Added functions for getting all datasets and projects in a Tamr instance via `get_all` functions in `tc.dataset` and `tc.project` - - [#454](https://github.com/Datatamer/tamr-client/pull/454) Added first `tamr_client` tutorial "Get Tamr version" - - [#456](https://github.com/Datatamer/tamr-client/pull/456) Added first example `tamr_client` script `examples/get_tamr_version.py` - - [#461](https://github.com/Datatamer/tamr-client/pull/461) Added functions for golden record workflow operations in `tc.golden_records` - - [#462](https://github.com/Datatamer/tamr-client/issues/462) Added function for checking operations, raising exception when operation has failed - - [#469](https://github.com/Datatamer/tamr-client/pull/469) Added "Continuous Mastering" `tamr_client` tutorial - - **NEW FEATURES** - - [#383](https://github.com/Datatamer/tamr-client/issues/383) Now able to create an Operation from Job resource id - - **BREAKING CHANGES** - - [#468](https://github.com/Datatamer/tamr-client/pull/468) `Dataset.upsert_records` and `Dataset._update_records` no longer take general `**json_args` arguments and will only accept `ignore_nan` - - The `ignore_nan` argument in `Dataset.upsert_records`, `Dataset._update_records`, `Dataset.upsert_from_dataframe`, and `DatasetCollection.create_from_dataframe` is now deprecated and will be removed in a future release - -## 0.12.0 - **BETA** - Important: Do not use BETA features for production workflows. - - [#372](https://github.com/Datatamer/tamr-client/issues/372) TC:Design for unified datasets - - `AnyDataset` can be any type of dataset. - - Unified Dataset is `tc.dataset.unified.Dataset` - - Any other Dataset is `tc.dataset.dataset.Dataset` - - Added function to get unified dataset from its project - - Added function to commit unified dataset - - - [#367](https://github.com/Datatamer/tamr-client/issues/367) Support for projects: - - generic projects via `tc.project` - - Mastering projects via `tc.mastering.project` - - Support for streaming records from a dataset via `tc.record.stream` - - Support for operations via `tc.operations` - - `tc.TamrClientException` as a base class for all `tamr_client` exceptions - - **BUG FIXES** - - `from_geo_features` now returns information on the operation. - - **NEW FEATURES** - - Added user documentation on [Geospatial functionalities with GeoPandas](https://github.com/Datatamer/tamr-client/blob/master/docs/user-guide/geo.md). Documented limitations in Geopandas and workarounds. - - [#366](https://github.com/Datatamer/tamr-client/issues/366) Now able to connect to Tamr instance with implicit port - - [#376](https://github.com/Datatamer/tamr-client/issues/376) Added user documentation on [Working with Pandas](https://github.com/Datatamer/tamr-client/blob/master/docs/user-guide/pandas.md). - -## 0.11.0 - **BETA** - - Important: Do not use BETA features for production workflows. - - New `tamr_client` package includes: - - - attributes - - `tc.attribute` module - - `tc.Attribute` type - - functions: `from_resource_id`, `from_dataset_all`, `to_json`, `create`, `update`, `delete` - - `tc.attribute_type` module - - `tc.AttributeType` for type annotations - - Primitive Types: `BOOLEAN`, `DOUBLE`, `INT`, `LONG`, `STRING` - - Complex Types: `Array`, `Map`, `Record` - - functions: `from_json`, `to_json` - - `tc.subattribute` module - - `tc.SubAttribute` type - - functions: `from_json`, `to_json` - - `tc.attributes.type_alias` module - - `tc.attributes.type_alias.DEFAULT` type - - `tc.attributes.type_alias.GEOSPATIAL` type - - datasets - - `tc.dataset` module - - `tc.Dataset` type - - functions: `from_resource_id` - - `tc.record` module - - functions: `tc.record.upsert`, `tc.record.delete` - - `tc.dataframe` module - - functions: `tc.dataframe.upsert` - - `tc.instance` module - - `tc.Instance` type - - functions: `tc.instance.from_auth` - - other supporting modules - - `tc.auth` module - - `tc.UsernamePasswordAuth` type - - `tc.session` module - - `tc.Session` type - - functions: `tc.session.from_auth` - - `tc.url` module - - `tc.URL` type - - `tc.response` module - - functions: `successful`, `ndjson` - - **NEW FEATURES** - - [#35](https://github.com/Datatamer/tamr-client/issues/35) projects.by_name() functionality added. Can now fetch a project by its name. - - [#377](https://github.com/Datatamer/tamr-client/issues/377) dataset.upsert_from_dataframe() functionality added. Can now upsert records from a pandas DataFrame. - - **BUG FIXES** - - Links from our docs to the `requests` docs were outdated. Links have been updated to point to the new `requests` docs URL. - - [#323](https://github.com/Datatamer/tamr-client/issues/323) Documentation for setting `dtype=str` before calling `client.datasets.create_from_dataframe` - -## 0.10.0 - **BREAKING CHANGES** - - [#309](https://github.com/Datatamer/tamr-client/issues/309) Migrate `SubAttribute` to use `@dataclass(frozen=True)`. `SubAttribute.__init__` constructor replaced with the one generated by `@dataclass`. `SubAttribute`s should be constructed via the `SubAttribute.from_json` static method. - - [#307](https://github.com/Datatamer/tamr-client/issues/307) Logging improvements: (1) Use standard logging best practices (2) log response body for responses containing HTTP error codes. Previous way to configure logging (via `Client.logger` and `Client.log_entry`) have been replaced. See [User Guide > Logging](https://tamr-client.readthedocs.io/en/latest/user-guide/logging.html). - - **NEW FEATURES** - - [#295](https://github.com/Datatamer/tamr-client/issues/295) Official support for Python 3.6+. CI now tests against Python 3.6, 3.7, 3.8 . - - **BUG FIXES** - - [#293](https://github.com/Datatamer/tamr-client/issues/293) Better handling for HTTP 204 on already up-to-date operations - -## 0.9.0 - **BREAKING CHANGES** - - `AttributeMapping.__init__()` now takes arguments `client` and `data` (used to take only `data`). - - **NEW FEATURES** - - [#218](https://github.com/Datatamer/unify-client-python/issues/218) Delete a `BaseResource` - - [#233](https://github.com/Datatamer/unify-client-python/issues/233) Remove an input dataset from a project - - [#67](https://github.com/Datatamer/unify-client-python/issues/67) Create a dataset from a pandas `DataFrame` - - [#222](https://github.com/Datatamer/unify-client-python/issues/222) Dataset spec to update an existing dataset - - [#225](https://github.com/Datatamer/unify-client-python/issues/225) Attribute configuration spec to update an existing attribute configuration - - [#223](https://github.com/Datatamer/unify-client-python/issues/223) Update an attribute with an attribute spec - - [#224](https://github.com/Datatamer/unify-client-python/issues/224) Project spec to update a project - - [#275](https://github.com/Datatamer/unify-client-python/issues/275) Create a category with a category spec - - [#273](https://github.com/Datatamer/unify-client-python/issues/273) Attribute type spec to allow for attribute creation - - [#219](https://github.com/Datatamer/tamr-client/issues/219) Delete a resource from collection. - - [#277](https://github.com/Datatamer/unify-client-python/issues/277) Attribute mapping spec - - [#226](https://github.com/Datatamer/tamr-client/issues/226) Update published cluster configurations with put - - [#246](https://github.com/Datatamer/tamr-client/issues/246) Cascading dataset delete - - [#221](https://github.com/Datatamer/unify-client-python/issues/221) delete an attribute mapping - - **BUG FIXES** - - [#235](https://github.com/Datatamer/unify-client-python/issues/235) Making `AttributeCollection` retrieve attributes directly instead of by streaming - - [#234](https://github.com/Datatamer/tamr-client/issues/234) Project's `resource`'s `add_input_dataset` now uses params instead of constructing resource ID manually - - [#256](https://github.com/Datatamer/unify-client-python/issues/256) Record and published clusters refresh did not use the correct endpoint - -## 0.8.0 - **BREAKING CHANGES** - - [#175](https://github.com/Datatamer/unify-client-python/issues/175) `AttributeCollection` no longer has a `from_json` method or a `data` parameter in its constructor - - `AttributeType` no longer inherits from `BaseResource` (no API path), removing its `from_json` method and `relative_id` property - - The type of `AttributeType`'s `attributes` property is now a `list` of `SubAttribute`s, which are identical to `Attribute`s except they lack an API path - - The `Dataset` function `update_records` has been renamed `_update_records` as the convenience functions `upsert_records` and `delete_records` now exist. - - All files have been refactored: - * The `models` directory has been deleted, everything previously in it has been moved directly into the base directory - * `DatasetProfile` and `DatasetStatus` have been moved into the `dataset` directory - * `machine_learning_model.py` has been renamed `base_model.py` - * Attribute configurations have been moved to a subdirectory within `project` - * A `mastering` directory has been created with all mastering specific entities - * A `categorization` directory has been created with all categorization specific entities, including a `category` subdirectory - - **NEW FEATURES** - - [#174](https://github.com/Datatamer/unify-client-python/issues/174) Get and create taxonomy categories - - [#182](https://github.com/Datatamer/unify-client-python/issues/182) Add the ability to refresh estimated pair counts. - - [#184](https://github.com/Datatamer/unify-client-python/issues/184) Support for getting published cluster configurations - - [#201](https://github.com/Datatamer/unify-client-python/issues/201) Support for refreshing published cluster IDs - - [#112](https://github.com/Datatamer/unify-client-python/issues/112) Support for attribute configurations - - [#181](https://github.com/Datatamer/unify-client-python/issues/181) Support for seeing a dataset's usage - - [#202](https://github.com/Datatamer/unify-client-python/issues/202) Support for refreshing published cluster stats - - [#194](https://github.com/Datatamer/unify-client-python/issues/194) Allowing additional JSON parameters to be used for update of records - - [#205](https://github.com/Datatamer/unify-client-python/issues/205) Update a dataset's records with records rather than record updates - - [#111](https://github.com/Datatamer/unify-client-python/issues/111) support for schema mapping attributes - - Delete records from a dataset by providing records rather than record updates - - [#183](https://github.com/Datatamer/unify-client-python/issues/183) Retrieve versions of published clusters - - [#220](https://github.com/Datatamer/unify-client-python/issues/220) Delete all records from a dataset - - [#185](https://github.com/Datatamer/unify-client-python/issues/185) Retrieve versions of published clusters for records - - [#180](https://github.com/Datatamer/unify-client-python/issues/180) Support for getting a dataset's upstream datasets - - **BUG FIXES** - - [#212](https://github.com/Datatamer/unify-client-python/issues/212) Sped up slow running tests - -## 0.7.0 - **BREAKING CHANGES** - - [#156](https://github.com/Datatamer/unify-client-python/issues/156) Fetch Dataset profile, even if out of date. - - [#161](https://github.com/Datatamer/unify-client-python/issues/161) Move `create_attribute` from Dataset to AttributeCollection - - The `Project` method `add_source_dataset` has been renamed `add_input_dataset` to model the API endpoint. - - **NEW FEATURES** - - [#65](https://github.com/Datatamer/unify-client-python/issues/65) Fetches published clusters with data represented as a dataset. - - [#155](https://github.com/Datatamer/unify-client-python/issues/155) Adds read-only support for binning model. - - [#165](https://github.com/Datatamer/unify-client-python/issues/165) Add `geo_attr` parameter to `Dataset.itergeofeatures()` and `Dataset.from_geo_features()` - - [#113](https://github.com/Datatamer/unify-client-python/issues/113) Add support for uploading a binningModel - - [#168](https://github.com/Datatamer/unify-client-python/issues/168) Add support for project attributes - - [#171](https://github.com/Datatamer/unify-client-python/issues/171) Add support for creating and retrieving taxonomies - - [#178](https://github.com/Datatamer/unify-client-python/issues/178) Add support for retrieving input datasets - - **BUG FIXES** - - [#148](https://github.com/Datatamer/unify-client-python/issues/148) Fix null geo, id and absent id bug for geospatial datasets. - - [#161](https://github.com/Datatamer/unify-client-python/issues/161) DatasetCollection.create() and ProjectCollection.create() don't work - - [#165](https://github.com/Datatamer/unify-client-python/issues/165) Dataset.itergeofeatures() is too slow - -## 0.6.0 - **BREAKING CHANGES** - - [#150](https://github.com/Datatamer/unify-client-python/issues/150) Move `create_project` and `create_dataset` from client.py to corresponding collection.py - - **NEW FEATURES** - - [#121](https://github.com/Datatamer/unify-client-python/issues/121) Fetches record clusters with data represented as a dataset. - - **BUG FIXES** - - [#140](https://github.com/Datatamer/unify-client-python/issues/140) Dataset `itergeofeatures` now supports data with geo attribute NULL - - [#123](https://github.com/Datatamer/unify-client-python/issues/123) Fix base_path bug for a custom api endpoint - -## 0.5.0 - **NEW FEATURES** - - [#94](https://github.com/Datatamer/unify-client-python/issues/94) Add access to Attributes of a Dataset - - [#103](https://github.com/Datatamer/unify-client-python/issues/103) Dataset `update_records` now returns the JSON response body for the underlying `POST datasets/{id}:updateRecords` call - - [#98](https://github.com/Datatamer/unify-client-python/issues/98) Add `__geo_interface__` to Dataset - - [#100](https://github.com/Datatamer/unify-client-python/issues/100) Add `from_geo_features` to Dataset - - [#116](https://github.com/Datatamer/unify-client-python/issues/116) Add support for associating a dataset with a project - - [#109](https://github.com/Datatamer/unify-client-python/issues/109) Add support for profiling datasets - - [#86](https://github.com/Datatamer/unify-client-python/issues/86) Add support for creating projects - - [#114](https://github.com/Datatamer/unify-client-python/issues/114) Add support for generating pairs estimate - - [#106](https://github.com/Datatamer/unify-client-python/issues/106) Add support for initializing a source dataset - - [#107](https://github.com/Datatamer/unify-client-python/issues/107) Add support for creating a dataset attribute - - **BUG FIXES** - - [#118](https://github.com/Datatamer/unify-client-python/issues/118) Fix JSON sent for Dataset.update_records - -## 0.4.0 - **BREAKING CHANGES** - - [#61](https://github.com/Datatamer/unify-client-python/issues/61) `data` field renamed to `_data` (private). - - [#78](https://github.com/Datatamer/unify-client-python/issues/78) Property accessors return `None` rather than raise `KeyError` - - **NEW FEATURES** - - Record Clusters API endpoint to finish working mastering workflow. - - [#78](https://github.com/Datatamer/unify-client-python/issues/78) Improved repr for objects through the library - - [#42](https://github.com/Datatamer/unify-client-python/issues/42) Optional `session` argument to `Client` to use a specific `requests.Session` instance - - **BUG FIXES** - - Mastering workflow example was missing the generate clusters step, which has been rectified using proper endpoint - - [#30](https://github.com/Datatamer/unify-client-python/issues/30) Better docs for how to call directly call APIs - - [#61](https://github.com/Datatamer/unify-client-python/issues/61) `data` field renamed to `_data` (private). - -## 0.3.0 -*released on 2019-3-1* - - **NEW FEATURES** - - Versioning example in FAQ - - Offline installation docs - - `by_external_id` methods for `Dataset` and `Project` - - `DatasetStatus` resource (subresource of `Dataset`) - - `Client.request` accepts absolute paths as relative to origin - - **BUG FIXES** - - `requests` version specified changed to `>=2.20.0` for Airflow compatibility - - `setup.py` reads `VERSION.txt` and `README.md` with explicit `utf-8` encodings - -## 0.2.0 -*released on 2019-1-17* - - **NEW FEATURES** - - [Docs via readthedocs](https://tamr-unify-python-client.readthedocs.io/en/stable/) - - [CI testing via TravisCI](https://travis-ci.org/Datatamer/unify-client-python) ([details](https://github.com/Datatamer/unify-client-python/commit/ae381ce29593a70ed992f88a3e3ef3eb170a5cd4)) - - Release process documented in [RELEASE.md](https://github.com/Datatamer/unify-client-python/blob/master/RELEASE.md) ([details](https://github.com/Datatamer/unify-client-python/commit/fe717bbddca96b82bc1e447a93ae5c8817481675)) - - README Badges - - Version, Python version, License, Codestyle ([details](https://github.com/Datatamer/unify-client-python/pull/1)) - - Docs ([details](https://github.com/Datatamer/unify-client-python/pull/14)) - - CI build/test ([details](https://github.com/Datatamer/unify-client-python/pull/19)) - - HTTP errors raised as exceptions. More helpful than always getting `JSONDecodeError`s. ([details](https://github.com/Datatamer/unify-client-python/pull/7)) - - Stream records from a dataset ([details](https://github.com/Datatamer/unify-client-python/pull/13)) - - Migrate all Python Client docs from docs.tamr.com to Sphinx docs ([details](https://github.com/Datatamer/unify-client-python/pull/21)) - - **BUG FIXES** - - PyPI metadata - - `-` not `_` in project name ([details](https://github.com/Datatamer/unify-client-python/commit/5e25c45ec9bff0d0f9f40f52e81aacecdccb3e1b)) - - correct github repo URL ([details](https://github.com/Datatamer/unify-client-python/commit/767cf537f247d20293aa3a81b7830534aa6f84ec)) - - "Apache 2.0" as license value ([details](https://github.com/Datatamer/unify-client-python/pull/2)) - - README now parsed/rendered as Markdown ([details](https://github.com/Datatamer/unify-client-python/pull/4)) - - Change Log for 0.1.0 release ([details](https://github.com/Datatamer/unify-client-python/commit/852d6f0fd11f8ea33d2ea49d60a406f4e7267143)) - - readthedocs compatibility ([details](https://github.com/Datatamer/unify-client-python/pull/12)) - -## 0.1.0 -*released on 2019-1-10* - - Initial public release - - **BREAKING CHANGES** - - Protobuf-related dependencies ([details](https://github.com/pcattori/unify-client-python/commit/5f25bcf41ba64fce67c2cfc1bba81d382bc70efe)) - - **NEW FEATURES** - - Repo Documentation ([details](https://github.com/pcattori/unify-client-python/commit/5f25bcf41ba64fce67c2cfc1bba81d382bc70efe)) - - [CHANGELOG.md](https://github.com/Datatamer/unify-client-python/blob/master/CHANGELOG.md) - - [CODE_OF_CONDUCT.md](https://github.com/Datatamer/unify-client-python/blob/master/CODE_OF_CONDUCT.md) - - [LICENSE](https://github.com/Datatamer/unify-client-python/blob/master/LICENSE) - - [README.md](https://github.com/Datatamer/unify-client-python/blob/master/README.md) - - Version in [VERSION.txt](VERSION.txt) ([details](https://github.com/pcattori/unify-client-python/commit/41e93d4dba03bc7445f1935345bfd76cf45b877c)) - - **BUG FIXES** - - Reference documentation - - Autodoc should show inherited members ([details](https://github.com/pcattori/unify-client-python/commit/8356eb3d8ea995227e808a07d71de1bf3d7453c7)) - - Autodoc warning about `**` in `param` docstrings ([details](https://github.com/pcattori/unify-client-python/commit/2a204b294a41e4b9eea5cc383569f6303d3a5206)) - - Shortened Sphinx references with `~` ([details](https://github.com/pcattori/unify-client-python/commit/9827e98dd7dab4eaeaef5e60197e280649de3737)) diff --git a/docs/user-guide/faq.md b/docs/user-guide/faq.md index 8243ffc3..70455152 100644 --- a/docs/user-guide/faq.md +++ b/docs/user-guide/faq.md @@ -6,7 +6,7 @@ The Python Client just cares about features, and will try everything it knows to If you are starting a new project or your existing project does not yet use the Python Client, we encourage you to use the **latest stable version** of the Python Client. -Otherwise, check the [Change Log](https://github.com/Datatamer/tamr-client/blob/master/CHANGELOG.md) to see: +Otherwise, check the [Releases](https://github.com/Datatamer/tamr-client/releases) to see: * what new features and bug fixes are available in newer versions * which breaking changes (if any) will require changes in your code to get those new features and bug fixes From 9483f599ff9eea481760d1e4a70a571008bb7c82 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 7 Jan 2021 14:57:34 -0500 Subject: [PATCH 622/632] docs: explain conventional commits and semantic-release to contributors --- docs/contributor-guide.md | 3 +++ docs/contributor-guide/pull-request.md | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/contributor-guide.md b/docs/contributor-guide.md index d6a5645c..f20b55c8 100644 --- a/docs/contributor-guide.md +++ b/docs/contributor-guide.md @@ -28,6 +28,9 @@ Submit feature requests as [Github issues](https://github.com/Datatamer/tamr-cli * [How to write tests](contributor-guide/how-to-write-tests) * [Submit a pull request](contributor-guide/pull-request) +## Release process +Releases are automated by [semantic-release](https://semantic-release.gitbook.io/semantic-release/). + ## Maintainers Maintainer responsabilities: diff --git a/docs/contributor-guide/pull-request.md b/docs/contributor-guide/pull-request.md index 983e23a4..263e5eb9 100644 --- a/docs/contributor-guide/pull-request.md +++ b/docs/contributor-guide/pull-request.md @@ -37,7 +37,12 @@ Contributions / PRs should follow the Split and squash commits as necessary to create a clean `git` history. Once you ask for review, only add new commits (do not change existing commits) for reviewer convenience. You may change commits in your PR only if reviewers are ok with it. -Also, write [good commit messages](https://chris.beams.io/posts/git-commit/)! +Commit messages **must** follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). +CI for pull requests will enforce this and fail if commit messages are not formatted correctly. + +We recommend the [Commitzen CLI](https://github.com/commitizen/cz-cli) to make writing Conventional Commits easy, but you may write commit messages manually or use any other tools. + +Also, your commit messages should [explain any things that are not obvious](https://chris.beams.io/posts/git-commit/#why-not-how) from reading your code! ### CI checks From 8c3935b5bf1b3c5cc586cad2bf82975613378259 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 7 Jan 2021 21:14:53 -0500 Subject: [PATCH 623/632] ci(release): only run release CI after other checks have passed --- .github/workflows/ci.yml | 26 ++++++++++++++++++++++++++ .github/workflows/release.yml | 31 ------------------------------- 2 files changed, 26 insertions(+), 31 deletions(-) delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6d6c17b..1680bee6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,3 +101,29 @@ jobs: run: npm install -g @commitlint/cli @commitlint/config-conventional - name: Run commitlint run: commitlint --from=origin/master + + Release: + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + needs: [Lint, Format, Typecheck, Test, Docs, Commitlint] + steps: + - uses: actions/checkout@v2 + - name: Install Python + uses: actions/setup-python@v2 + with: + python-version: 3.6 + - name: Install poetry and toml-cli + run: | + pip install --upgrade pip + pip install poetry==1.1.4 + pip install toml-cli==0.1.3 + - name: Install npm + uses: actions/setup-node@v2 + with: + node-version: "14" + - name: Install semantic-release + run: npm install -g semantic-release@17 @semantic-release/exec@5 + - name: Run semantic-release + env: + POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }} + run: semantic-release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 85393d39..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Release - -on: - push: - branches: - - master - -jobs: - Publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Install Python - uses: actions/setup-python@v2 - with: - python-version: 3.6 - - name: Install poetry and toml-cli - run: | - pip install --upgrade pip - pip install poetry==1.1.4 - pip install toml-cli==0.1.3 - - name: Install npm - uses: actions/setup-node@v2 - with: - node-version: "14" - - name: Install semantic-release - run: npm install -g semantic-release@17 @semantic-release/exec@5 - - name: Run semantic-release - env: - POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }} - run: semantic-release From 1916f25026485430b5c5ee16325e906377f086c6 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 7 Jan 2021 21:15:57 -0500 Subject: [PATCH 624/632] ci: do not run CI on obsoleted release branches With automated releases via semantic-release, there will be no need for release branches anymore. --- .github/workflows/ci.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1680bee6..7256b04d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,14 +1,12 @@ name: CI on: - push: + pull_request: branches: - master - - release-* - pull_request: + push: branches: - master - - release-* jobs: Lint: From e3aef7dab993e8c4f1837c7942969e302ee09d82 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 7 Jan 2021 21:18:17 -0500 Subject: [PATCH 625/632] ci(release): fix github auth See [semantic-release's authentication docs][1] and [Github Action's authentication docs][2] [1] https://semantic-release.gitbook.io/semantic-release/usage/ci-configuration#authentication [2] https://docs.github.com/en/free-pro-team@latest/actions/reference/authentication-in-a-workflow --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7256b04d..87b69522 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,5 +123,6 @@ jobs: run: npm install -g semantic-release@17 @semantic-release/exec@5 - name: Run semantic-release env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }} run: semantic-release From 3846350dfecaf2e8b6c6fffd47d30f9e0fbcdf49 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 7 Jan 2021 21:50:44 -0500 Subject: [PATCH 626/632] docs(readme): remove reference to nonexistant changelog --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 5edfd857..f4876c6b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ Programmatically 💻 interact with Tamr using Python 🐍 *Quick links:* **[Docs](https://tamr-client.readthedocs.io/en/stable/)** | **[Contributing](https://tamr-client.readthedocs.io/en/stable/contributor-guide.html)** | -**[Change Log](https://github.com/Datatamer/tamr-client/blob/master/CHANGELOG.md)** | **[License](https://github.com/Datatamer/tamr-client/blob/master/LICENSE)** --- From f63f7982aa9a3351c3d4531389769bf3e13947a3 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 5 Feb 2021 11:50:05 -0500 Subject: [PATCH 627/632] fix(tc): change record.stream to be a generator instead of returning a generator that is empty --- tamr_client/dataset/record.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_client/dataset/record.py b/tamr_client/dataset/record.py index ad791968..0035bf0e 100644 --- a/tamr_client/dataset/record.py +++ b/tamr_client/dataset/record.py @@ -148,7 +148,7 @@ def stream(session: Session, dataset: AnyDataset) -> Iterator[JsonDict]: Python generator yielding records """ with session.get(str(dataset.url) + "/records", stream=True) as r: - return response.ndjson(r) + yield from response.ndjson(r) def delete_all(session: Session, dataset: AnyDataset): From 378983313dfeefd188336c7faf39318b7d098b1d Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 5 Feb 2021 13:31:32 -0500 Subject: [PATCH 628/632] feat(tc): add a catch-all project dataclass to handle project types not explicitly defined --- docs/beta/project.rst | 2 ++ tamr_client/__init__.py | 1 + tamr_client/_types/__init__.py | 1 + tamr_client/_types/project.py | 23 ++++++++++++++++++++++- tamr_client/project.py | 6 ++++-- tests/tamr_client/test_project.py | 16 +++++++++++++--- 6 files changed, 43 insertions(+), 6 deletions(-) diff --git a/docs/beta/project.rst b/docs/beta/project.rst index ad2e9b8a..ecd0c3bc 100644 --- a/docs/beta/project.rst +++ b/docs/beta/project.rst @@ -1,6 +1,8 @@ Project ======= +.. autoclass:: tamr_client.UnknownProject + .. autofunction:: tamr_client.project.by_resource_id .. autofunction:: tamr_client.project.by_name .. autofunction:: tamr_client.project.get_all diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index fc1ee1c1..2e42cb46 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -35,6 +35,7 @@ SubAttribute, Transformations, UnifiedDataset, + UnknownProject, URL, UsernamePasswordAuth, ) diff --git a/tamr_client/_types/__init__.py b/tamr_client/_types/__init__.py index 6d52f2fb..b5f616c3 100644 --- a/tamr_client/_types/__init__.py +++ b/tamr_client/_types/__init__.py @@ -27,6 +27,7 @@ MasteringProject, Project, SchemaMappingProject, + UnknownProject, ) from tamr_client._types.restore import Restore from tamr_client._types.session import Session diff --git a/tamr_client/_types/project.py b/tamr_client/_types/project.py index 03c213b8..32e84114 100644 --- a/tamr_client/_types/project.py +++ b/tamr_client/_types/project.py @@ -72,6 +72,27 @@ class GoldenRecordsProject: description: Optional[str] = None +@dataclass(frozen=True) +class UnknownProject: + """A Tamr project of an unrecognized type + + See https://docs.tamr.com/reference/the-project-object + + Args: + url + name + description + """ + + url: URL + name: str + description: Optional[str] = None + + Project = Union[ - CategorizationProject, MasteringProject, SchemaMappingProject, GoldenRecordsProject + CategorizationProject, + MasteringProject, + SchemaMappingProject, + GoldenRecordsProject, + UnknownProject, ] diff --git a/tamr_client/project.py b/tamr_client/project.py index 00e24242..6a964912 100644 --- a/tamr_client/project.py +++ b/tamr_client/project.py @@ -1,7 +1,7 @@ from typing import List, Optional, Tuple, Union from tamr_client import response -from tamr_client._types import Instance, JsonDict, Project, Session, URL +from tamr_client._types import Instance, JsonDict, Project, Session, UnknownProject, URL from tamr_client.categorization import project as categorization_project from tamr_client.exception import TamrClientException from tamr_client.golden_records import project as golden_records_project @@ -110,7 +110,9 @@ def _from_json(url: URL, data: JsonDict) -> Project: elif proj_type == "GOLDEN_RECORDS": return golden_records_project._from_json(url, data) else: - raise ValueError(f"Unrecognized project type '{proj_type}' in {repr(data)}") + return UnknownProject( + url, name=data["name"], description=data.get("description") + ) def _create( diff --git a/tests/tamr_client/test_project.py b/tests/tamr_client/test_project.py index c198add0..444bd6e5 100644 --- a/tests/tamr_client/test_project.py +++ b/tests/tamr_client/test_project.py @@ -132,6 +132,16 @@ def test_create_project_already_exists(): def test_from_json_unrecognized_project_type(): instance = fake.instance() url = tc.URL("project/1", instance) - data: tc._types.JsonDict = {"type": "NOT_A_PROJECT_TYPE"} - with pytest.raises(ValueError): - tc.project._from_json(url, data) + data: tc._types.JsonDict = { + "id": "unify://unified-data/v1/projects/1", + "name": "project 1", + "description": "A project of unknown type", + "type": "UNKNOWN", + "unifiedDatasetName": "", + "relativeId": "projects/1", + "externalId": "58bdbe72-3c08-427d-97bd-45b16d92c79c", + } + project = tc.project._from_json(url, data) + assert isinstance(project, tc.UnknownProject) + assert project.name == "project 1" + assert project.description == "A project of unknown type" From 08270a9af77315d2726584390376a78f511595c4 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 5 Feb 2021 14:24:21 -0500 Subject: [PATCH 629/632] ci(release): do not use pip to install poetry The poetry docs explicitly warn against using pip for installation. See https://python-poetry.org/docs/#installing-with-pip. Instead, we'll use a Github Action tailor-made for installing poetry. --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87b69522..591371e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,10 +110,13 @@ jobs: uses: actions/setup-python@v2 with: python-version: 3.6 - - name: Install poetry and toml-cli + - name: Install poetry + uses: snok/install-poetry@v1.1.1 + with: + version: 1.1.4 + - name: Install toml-cli run: | pip install --upgrade pip - pip install poetry==1.1.4 pip install toml-cli==0.1.3 - name: Install npm uses: actions/setup-node@v2 From bee618363c7434a869c035aed8d27a5fbf7cf2a9 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 14 Jan 2021 16:25:30 -0500 Subject: [PATCH 630/632] feat(tc): add composite function to create a dataset from a pandas dataframe --- README.md | 1 + docs/beta/dataset/dataframe.rst | 1 + tamr_client/dataset/dataframe.py | 145 ++++++++++++++++++++++++++++--- 3 files changed, 136 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f4876c6b..5bbeb50d 100644 --- a/README.md +++ b/README.md @@ -39,3 +39,4 @@ For more see the [official docs](https://tamr-client.readthedocs.io/en/stable/). ## Maintainers - [Pedro Cattori](https://github.com/pcattori) +- [Samuel Kalish](https://github.com/skalish) diff --git a/docs/beta/dataset/dataframe.rst b/docs/beta/dataset/dataframe.rst index ec02b9e2..f63964f8 100644 --- a/docs/beta/dataset/dataframe.rst +++ b/docs/beta/dataset/dataframe.rst @@ -2,3 +2,4 @@ Dataframe ========= .. autofunction:: tamr_client.dataframe.upsert +.. autofunction:: tamr_client.dataframe.create diff --git a/tamr_client/dataset/dataframe.py b/tamr_client/dataset/dataframe.py index 2274eeae..158534d7 100644 --- a/tamr_client/dataset/dataframe.py +++ b/tamr_client/dataset/dataframe.py @@ -6,15 +6,25 @@ import os from typing import Optional, TYPE_CHECKING -from tamr_client import primary_key -from tamr_client._types import Dataset, JsonDict, Session +import requests + +from tamr_client import attribute, dataset, primary_key +from tamr_client._types import Dataset, Instance, JsonDict, Session from tamr_client.dataset import record +from tamr_client.exception import TamrClientException BUILDING_DOCS = os.environ.get("TAMR_CLIENT_DOCS") == "1" if TYPE_CHECKING or BUILDING_DOCS: import pandas as pd +class CreationFailure(TamrClientException): + """Raised when a dataset could not be created from a pandas DataFrame + """ + + pass + + def upsert( session: Session, dataset: Dataset, @@ -27,7 +37,8 @@ def upsert( Args: dataset: Dataset to receive record updates df: The DataFrame containing records to be upserted - primary_key_name: The primary key of the dataset. Must be a column of `df`. By default the key_attribute_name of dataset + primary_key_name: The primary key of the dataset. Must be a column of `df`. By default the + key_attribute_name of dataset Returns: JSON response body from the server @@ -41,14 +52,7 @@ def upsert( primary_key_name = dataset.key_attribute_names[0] # preconditions - if primary_key_name in df.columns and primary_key_name == df.index.name: - raise primary_key.Ambiguous( - f"Index {primary_key_name} has the same name as column {primary_key_name}" - ) - elif primary_key_name not in df.columns and primary_key_name != df.index.name: - raise primary_key.NotFound( - f"Primary key: {primary_key_name} is not DataFrame index name: {df.index.name} or in DataFrame column names: {df.columns}" - ) + _check_primary_key(df, primary_key_name) # promote primary key column to index if primary_key_name in df.columns: @@ -60,3 +64,122 @@ def upsert( {primary_key_name: pk, **json.loads(row)} for pk, row in serialized_records ) return record.upsert(session, dataset, records, primary_key_name=primary_key_name) + + +def create( + session: Session, + instance: Instance, + df: "pd.DataFrame", + *, + name: str, + primary_key_name: Optional[str] = None, + description: Optional[str] = None, + external_id: Optional[str] = None, +) -> Dataset: + """Create a dataset in Tamr from the DataFrame `df` and creates a record from each row + + All attributes other than the primary key are created as the default type array(string) + + Args: + instance: Tamr instance + df: The DataFrame containing records to be upserted + name: Dataset name + primary_key_name: The primary key of the dataset. Must be a column of `df`. By default the + name of the index of `df` + description: Dataset description + external_id: External ID of the dataset + + Returns: + Dataset created in Tamr + + Raises: + dataset.AlreadyExists: If a dataset with these specifications already exists. + requests.HTTPError: If any other HTTP error is encountered. + primary_key.NotFound: If `primary_key_name` is not a column in `df` or the index of `df` + ValueError: If `primary_key_name` matches both a column in `df` and the index of `df` + """ + # preconditions + if primary_key_name is None: + if df.index.name is not None: + primary_key_name = df.index.name + else: + raise primary_key.NotFound( + "No primary key was specified and DataFrame index is unnamed" + ) + _check_primary_key(df, primary_key_name) + + # dataset creation + try: + ds = dataset.create( + session, + instance, + name=name, + key_attribute_names=(primary_key_name,), + description=description, + external_id=external_id, + ) + except (TamrClientException, requests.HTTPError) as e: + raise CreationFailure(f"Dataset was not created: {e}") + + # attribute creation + for col in df.columns: + if col == primary_key_name: + # this attribute already exists as a key attribute + continue + try: + attribute.create(session, ds, name=col, is_nullable=True) + except (TamrClientException, requests.HTTPError) as e: + _handle_creation_failure(session, ds, f"An attribute was not created: {e}") + + # record creation + try: + response = upsert(session, ds, df, primary_key_name=primary_key_name) + if not response["allCommandsSucceeded"]: + _handle_creation_failure(session, ds, "Some records had validation errors") + except (TamrClientException, requests.HTTPError) as e: + _handle_creation_failure(session, ds, f"Record could not be created: {e}") + + # Get Dataset from server + return dataset._dataset._by_url(session, ds.url) + + +def _handle_creation_failure(session: Session, stub_dataset: Dataset, error: str): + """Attempt to make `dataframe.create` atomic by deleting the created dataset in the event of + later failure. + + However, this does not guarantee atomicity: if the request to delete the dataset fails, it will + not retry. + + Args: + stub_dataset: The created dataset to delete + error: The error that caused dataset creation to fail + """ + try: + dataset.delete(session, stub_dataset) + except requests.HTTPError: + raise CreationFailure( + f"Created dataset did not delete after an earlier error: {error}" + ) + raise CreationFailure(error) + + +def _check_primary_key(df: "pd.DataFrame", primary_key_name: str): + """Check if the primary key name uniquely identifies a column or index of the DataFrame + + Args: + df: The DataFrame to inspect + primary_key_name: The index or column name to be used as the primary key + + Raises: + primary_key.Ambiguous: If the primary key name matches both the index and a column + primary_key.NotFound: If the primary key name does not match the index or any column + """ + if primary_key_name in df.columns and primary_key_name == df.index.name: + raise primary_key.Ambiguous( + f"Index {primary_key_name} has the same name as column {primary_key_name}" + ) + elif primary_key_name not in df.columns and primary_key_name != df.index.name: + raise primary_key.NotFound( + f"Primary key: {primary_key_name} is not DataFrame index name: {df.index.name} or in" + f" DataFrame column names: {df.columns}" + ) From 4cf95f4270aed3b83bc458538c4df174775229e6 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 14 Jan 2021 16:26:47 -0500 Subject: [PATCH 631/632] test(tc): add tests of dataframe.create_dataset and its error handling --- tests/tamr_client/dataset/test_dataframe.py | 150 ++++++++++------ .../dataset/test_dataframe/test_create.json | 168 ++++++++++++++++++ .../test_create_deletion_failure.json | 103 +++++++++++ .../test_create_handle_attribute_failure.json | 103 +++++++++++ .../test_create_handle_record_failure.json | 141 +++++++++++++++ ...t_create_infer_primary_key_from_index.json | 168 ++++++++++++++++++ .../dataset/test_dataframe/test_upsert.json | 32 ++++ .../test_upsert_index_as_primary_key.json | 34 ++++ .../test_upsert_infer_primary_key.json | 32 ++++ .../dataset/test_dataset/test_create.json | 2 +- 10 files changed, 877 insertions(+), 56 deletions(-) create mode 100644 tests/tamr_client/fake_json/dataset/test_dataframe/test_create.json create mode 100644 tests/tamr_client/fake_json/dataset/test_dataframe/test_create_deletion_failure.json create mode 100644 tests/tamr_client/fake_json/dataset/test_dataframe/test_create_handle_attribute_failure.json create mode 100644 tests/tamr_client/fake_json/dataset/test_dataframe/test_create_handle_record_failure.json create mode 100644 tests/tamr_client/fake_json/dataset/test_dataframe/test_create_infer_primary_key_from_index.json create mode 100644 tests/tamr_client/fake_json/dataset/test_dataframe/test_upsert.json create mode 100644 tests/tamr_client/fake_json/dataset/test_dataframe/test_upsert_index_as_primary_key.json create mode 100644 tests/tamr_client/fake_json/dataset/test_dataframe/test_upsert_infer_primary_key.json diff --git a/tests/tamr_client/dataset/test_dataframe.py b/tests/tamr_client/dataset/test_dataframe.py index 65b97107..df49c13d 100644 --- a/tests/tamr_client/dataset/test_dataframe.py +++ b/tests/tamr_client/dataset/test_dataframe.py @@ -1,41 +1,21 @@ -from functools import partial -from typing import Dict - import pandas as pd import pytest -import responses import tamr_client as tc -from tests.tamr_client import fake, utils +from tests.tamr_client import fake -@responses.activate +@fake.json def test_upsert(): s = fake.session() dataset = fake.dataset() - url = tc.URL(path="datasets/1:updateRecords") - updates = [ - tc.record._create_command(record, primary_key_name="primary_key") - for record in _records_json - ] - snoop: Dict = {} - responses.add_callback( - responses.POST, - str(url), - partial( - utils.capture_payload, snoop=snoop, status=200, response_json=_response_json - ), - ) - df = pd.DataFrame(_records_json) response = tc.dataframe.upsert(s, dataset, df, primary_key_name="primary_key") assert response == _response_json - assert snoop["payload"] == utils.stringify(updates) -@responses.activate def test_upsert_primary_key_not_found(): s = fake.session() dataset = fake.dataset() @@ -46,51 +26,22 @@ def test_upsert_primary_key_not_found(): tc.dataframe.upsert(s, dataset, df, primary_key_name="wrong_primary_key") -@responses.activate +@fake.json def test_upsert_infer_primary_key(): s = fake.session() dataset = fake.dataset() - url = tc.URL(path="datasets/1:updateRecords") - updates = [ - tc.record._create_command(record, primary_key_name="primary_key") - for record in _records_json - ] - snoop: Dict = {} - responses.add_callback( - responses.POST, - str(url), - partial( - utils.capture_payload, snoop=snoop, status=200, response_json=_response_json - ), - ) - df = pd.DataFrame(_records_json) response = tc.dataframe.upsert(s, dataset, df) assert response == _response_json - assert snoop["payload"] == utils.stringify(updates) -@responses.activate +@fake.json def test_upsert_index_as_primary_key(): s = fake.session() dataset = fake.dataset() - url = tc.URL(path="datasets/1:updateRecords") - updates = [ - tc.record._create_command(record, primary_key_name="primary_key") - for record in _records_with_keys_json_2 - ] - snoop: Dict = {} - responses.add_callback( - responses.POST, - str(url), - partial( - utils.capture_payload, snoop=snoop, status=200, response_json=_response_json - ), - ) - df = pd.DataFrame( _records_json_2, index=[record["primary_key"] for record in _records_with_keys_json_2], @@ -99,10 +50,8 @@ def test_upsert_index_as_primary_key(): response = tc.dataframe.upsert(s, dataset, df, primary_key_name="primary_key") assert response == _response_json - assert snoop["payload"] == utils.stringify(updates) -@responses.activate def test_upsert_index_column_name_collision(): s = fake.session() dataset = fake.dataset() @@ -117,6 +66,97 @@ def test_upsert_index_column_name_collision(): tc.dataframe.upsert(s, dataset, df, primary_key_name="primary_key") +@fake.json +def test_create(): + s = fake.session() + instance = fake.instance() + + df = pd.DataFrame(_records_with_keys_json_2) + + dataset = tc.dataframe.create( + s, instance, df, name="df_dataset", primary_key_name="primary_key" + ) + assert dataset.name == "df_dataset" + assert dataset.key_attribute_names == ("primary_key",) + + +@fake.json +def test_create_infer_primary_key_from_index(): + s = fake.session() + instance = fake.instance() + + df = pd.DataFrame( + _records_json_2, + index=[record["primary_key"] for record in _records_with_keys_json_2], + ) + df.index.name = "primary_key" + + dataset = tc.dataframe.create(s, instance, df, name="df_dataset") + assert dataset.name == "df_dataset" + assert dataset.key_attribute_names == ("primary_key",) + + +def test_create_no_primary_key(): + s = fake.session() + instance = fake.instance() + + df = pd.DataFrame(_records_with_keys_json_2) + + with pytest.raises(tc.primary_key.NotFound): + tc.dataframe.create(s, instance, df, name="df_dataset") + + +def test_create_primary_key_not_found(): + s = fake.session() + instance = fake.instance() + + df = pd.DataFrame(_records_with_keys_json_2) + + with pytest.raises(tc.primary_key.NotFound): + tc.dataframe.create( + s, instance, df, name="df_dataset", primary_key_name="wrong_primary_key" + ) + + +@fake.json +def test_create_handle_attribute_failure(): + s = fake.session() + instance = fake.instance() + + df = pd.DataFrame(_records_with_keys_json_2) + + with pytest.raises(tc.dataframe.CreationFailure): + tc.dataframe.create( + s, instance, df, name="df_dataset", primary_key_name="primary_key" + ) + + +@fake.json +def test_create_deletion_failure(): + s = fake.session() + instance = fake.instance() + + df = pd.DataFrame(_records_with_keys_json_2) + + with pytest.raises(tc.dataframe.CreationFailure): + tc.dataframe.create( + s, instance, df, name="df_dataset", primary_key_name="primary_key" + ) + + +@fake.json +def test_create_handle_record_failure(): + s = fake.session() + instance = fake.instance() + + df = pd.DataFrame(_records_with_keys_json_2) + + with pytest.raises(tc.dataframe.CreationFailure): + tc.dataframe.create( + s, instance, df, name="df_dataset", primary_key_name="primary_key" + ) + + _records_json = [{"primary_key": 1}, {"primary_key": 2}] _records_json_2 = [{"attribute": 1}, {"attribute": 2}] diff --git a/tests/tamr_client/fake_json/dataset/test_dataframe/test_create.json b/tests/tamr_client/fake_json/dataset/test_dataframe/test_create.json new file mode 100644 index 00000000..1b40fa7a --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataframe/test_create.json @@ -0,0 +1,168 @@ +[ + { + "request": { + "method": "POST", + "path": "datasets", + "json": { + "name": "df_dataset", + "keyAttributeNames": [ + "primary_key" + ], + "description": null, + "externalId": null + } + }, + "response": { + "status": 201, + "json": { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "df_dataset", + "description": null, + "version": "dataset version", + "keyAttributeNames": [ + "primary_key" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] + } + } + }, + { + "request": { + "method": "GET", + "path": "datasets/1" + }, + "response": { + "status": 200, + "json": { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "df_dataset", + "description": null, + "version": "dataset version", + "keyAttributeNames": [ + "primary_key" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] + } + } + }, + { + "request": { + "method": "POST", + "path": "datasets/1/attributes", + "json": { + "name": "attribute", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + } + }, + "response": { + "status": 201, + "json": { + "name": "attribute", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + }, + "description": null + } + } + }, + { + "request": { + "method": "POST", + "path": "datasets/1:updateRecords", + "ndjson": [ + { + "action": "CREATE", + "recordId": 1, + "record": { + "primary_key": 1, + "attribute": 1 + } + }, + { + "action": "CREATE", + "recordId": 2, + "record": { + "primary_key": 2, + "attribute": 2 + } + } + ] + }, + "response": { + "status": 204, + "json": { + "numCommandsProcessed": 2, + "allCommandsSucceeded": true, + "validationErrors": [] + } + } + }, + { + "request": { + "method": "GET", + "path": "datasets/1" + }, + "response": { + "status": 200, + "json": { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "df_dataset", + "description": null, + "version": "dataset version", + "keyAttributeNames": [ + "primary_key" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/dataset/test_dataframe/test_create_deletion_failure.json b/tests/tamr_client/fake_json/dataset/test_dataframe/test_create_deletion_failure.json new file mode 100644 index 00000000..0f5cd930 --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataframe/test_create_deletion_failure.json @@ -0,0 +1,103 @@ +[ + { + "request": { + "method": "POST", + "path": "datasets", + "json": { + "name": "df_dataset", + "keyAttributeNames": [ + "primary_key" + ], + "description": null, + "externalId": null + } + }, + "response": { + "status": 201, + "json": { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "df_dataset", + "description": null, + "version": "dataset version", + "keyAttributeNames": [ + "primary_key" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] + } + } + }, + { + "request": { + "method": "GET", + "path": "datasets/1" + }, + "response": { + "status": 200, + "json": { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "df_dataset", + "description": null, + "version": "dataset version", + "keyAttributeNames": [ + "primary_key" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] + } + } + }, + { + "request": { + "method": "POST", + "path": "datasets/1/attributes", + "json": { + "name": "attribute", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + } + }, + "response": { + "status": 500, + "json": {} + } + }, + { + "request": { + "method": "DELETE", + "path": "datasets/1?cascade=false" + }, + "response": { + "status": 500 + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/dataset/test_dataframe/test_create_handle_attribute_failure.json b/tests/tamr_client/fake_json/dataset/test_dataframe/test_create_handle_attribute_failure.json new file mode 100644 index 00000000..845bd592 --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataframe/test_create_handle_attribute_failure.json @@ -0,0 +1,103 @@ +[ + { + "request": { + "method": "POST", + "path": "datasets", + "json": { + "name": "df_dataset", + "keyAttributeNames": [ + "primary_key" + ], + "description": null, + "externalId": null + } + }, + "response": { + "status": 201, + "json": { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "df_dataset", + "description": null, + "version": "dataset version", + "keyAttributeNames": [ + "primary_key" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] + } + } + }, + { + "request": { + "method": "GET", + "path": "datasets/1" + }, + "response": { + "status": 200, + "json": { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "df_dataset", + "description": null, + "version": "dataset version", + "keyAttributeNames": [ + "primary_key" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] + } + } + }, + { + "request": { + "method": "POST", + "path": "datasets/1/attributes", + "json": { + "name": "attribute", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + } + }, + "response": { + "status": 500, + "json": {} + } + }, + { + "request": { + "method": "DELETE", + "path": "datasets/1?cascade=false" + }, + "response": { + "status": 204 + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/dataset/test_dataframe/test_create_handle_record_failure.json b/tests/tamr_client/fake_json/dataset/test_dataframe/test_create_handle_record_failure.json new file mode 100644 index 00000000..d745a5d7 --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataframe/test_create_handle_record_failure.json @@ -0,0 +1,141 @@ +[ + { + "request": { + "method": "POST", + "path": "datasets", + "json": { + "name": "df_dataset", + "keyAttributeNames": [ + "primary_key" + ], + "description": null, + "externalId": null + } + }, + "response": { + "status": 201, + "json": { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "df_dataset", + "description": null, + "version": "dataset version", + "keyAttributeNames": [ + "primary_key" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] + } + } + }, + { + "request": { + "method": "GET", + "path": "datasets/1" + }, + "response": { + "status": 200, + "json": { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "df_dataset", + "description": null, + "version": "dataset version", + "keyAttributeNames": [ + "primary_key" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] + } + } + }, + { + "request": { + "method": "POST", + "path": "datasets/1/attributes", + "json": { + "name": "attribute", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + } + }, + "response": { + "status": 201, + "json": { + "name": "attribute", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + }, + "description": null + } + } + }, + { + "request": { + "method": "POST", + "path": "datasets/1:updateRecords", + "ndjson": [ + { + "action": "CREATE", + "recordId": 1, + "record": { + "primary_key": 1, + "attribute": 1 + } + }, + { + "action": "CREATE", + "recordId": 2, + "record": { + "primary_key": 2, + "attribute": 2 + } + } + ] + }, + "response": { + "status": 500, + "json": {} + } + }, + { + "request": { + "method": "DELETE", + "path": "datasets/1?cascade=false" + }, + "response": { + "status": 204 + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/dataset/test_dataframe/test_create_infer_primary_key_from_index.json b/tests/tamr_client/fake_json/dataset/test_dataframe/test_create_infer_primary_key_from_index.json new file mode 100644 index 00000000..1b40fa7a --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataframe/test_create_infer_primary_key_from_index.json @@ -0,0 +1,168 @@ +[ + { + "request": { + "method": "POST", + "path": "datasets", + "json": { + "name": "df_dataset", + "keyAttributeNames": [ + "primary_key" + ], + "description": null, + "externalId": null + } + }, + "response": { + "status": 201, + "json": { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "df_dataset", + "description": null, + "version": "dataset version", + "keyAttributeNames": [ + "primary_key" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] + } + } + }, + { + "request": { + "method": "GET", + "path": "datasets/1" + }, + "response": { + "status": 200, + "json": { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "df_dataset", + "description": null, + "version": "dataset version", + "keyAttributeNames": [ + "primary_key" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] + } + } + }, + { + "request": { + "method": "POST", + "path": "datasets/1/attributes", + "json": { + "name": "attribute", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + } + }, + "response": { + "status": 201, + "json": { + "name": "attribute", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + }, + "description": null + } + } + }, + { + "request": { + "method": "POST", + "path": "datasets/1:updateRecords", + "ndjson": [ + { + "action": "CREATE", + "recordId": 1, + "record": { + "primary_key": 1, + "attribute": 1 + } + }, + { + "action": "CREATE", + "recordId": 2, + "record": { + "primary_key": 2, + "attribute": 2 + } + } + ] + }, + "response": { + "status": 204, + "json": { + "numCommandsProcessed": 2, + "allCommandsSucceeded": true, + "validationErrors": [] + } + } + }, + { + "request": { + "method": "GET", + "path": "datasets/1" + }, + "response": { + "status": 200, + "json": { + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "df_dataset", + "description": null, + "version": "dataset version", + "keyAttributeNames": [ + "primary_key" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/dataset/test_dataframe/test_upsert.json b/tests/tamr_client/fake_json/dataset/test_dataframe/test_upsert.json new file mode 100644 index 00000000..de3de037 --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataframe/test_upsert.json @@ -0,0 +1,32 @@ +[ + { + "request": { + "method": "POST", + "path": "datasets/1:updateRecords", + "ndjson": [ + { + "action": "CREATE", + "recordId": 1, + "record": { + "primary_key": 1 + } + }, + { + "action": "CREATE", + "recordId": 2, + "record": { + "primary_key": 2 + } + } + ] + }, + "response": { + "status": 204, + "json": { + "numCommandsProcessed": 2, + "allCommandsSucceeded": true, + "validationErrors": [] + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/dataset/test_dataframe/test_upsert_index_as_primary_key.json b/tests/tamr_client/fake_json/dataset/test_dataframe/test_upsert_index_as_primary_key.json new file mode 100644 index 00000000..573f8f53 --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataframe/test_upsert_index_as_primary_key.json @@ -0,0 +1,34 @@ +[ + { + "request": { + "method": "POST", + "path": "datasets/1:updateRecords", + "ndjson": [ + { + "action": "CREATE", + "recordId": 1, + "record": { + "primary_key": 1, + "attribute": 1 + } + }, + { + "action": "CREATE", + "recordId": 2, + "record": { + "primary_key": 2, + "attribute": 2 + } + } + ] + }, + "response": { + "status": 204, + "json": { + "numCommandsProcessed": 2, + "allCommandsSucceeded": true, + "validationErrors": [] + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/dataset/test_dataframe/test_upsert_infer_primary_key.json b/tests/tamr_client/fake_json/dataset/test_dataframe/test_upsert_infer_primary_key.json new file mode 100644 index 00000000..de3de037 --- /dev/null +++ b/tests/tamr_client/fake_json/dataset/test_dataframe/test_upsert_infer_primary_key.json @@ -0,0 +1,32 @@ +[ + { + "request": { + "method": "POST", + "path": "datasets/1:updateRecords", + "ndjson": [ + { + "action": "CREATE", + "recordId": 1, + "record": { + "primary_key": 1 + } + }, + { + "action": "CREATE", + "recordId": 2, + "record": { + "primary_key": 2 + } + } + ] + }, + "response": { + "status": 204, + "json": { + "numCommandsProcessed": 2, + "allCommandsSucceeded": true, + "validationErrors": [] + } + } + } +] \ No newline at end of file diff --git a/tests/tamr_client/fake_json/dataset/test_dataset/test_create.json b/tests/tamr_client/fake_json/dataset/test_dataset/test_create.json index 0db82113..b79ee4cb 100644 --- a/tests/tamr_client/fake_json/dataset/test_dataset/test_create.json +++ b/tests/tamr_client/fake_json/dataset/test_dataset/test_create.json @@ -39,7 +39,7 @@ } } }, - { + { "request": { "method": "GET", "path": "datasets/1" From 8c2e010cc0d042d48606ccdadfd9874a22dce1b2 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 5 Feb 2021 11:45:22 -0500 Subject: [PATCH 632/632] docs(adr): add adr specifying policy on optimization of new features --- ...rm-performance-issues-before-optimizing.md | 24 +++++++++++++++++++ docs/contributor-guide/adrs.md | 3 ++- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 docs/contributor-guide/adr/0010-confirm-performance-issues-before-optimizing.md diff --git a/docs/contributor-guide/adr/0010-confirm-performance-issues-before-optimizing.md b/docs/contributor-guide/adr/0010-confirm-performance-issues-before-optimizing.md new file mode 100644 index 00000000..7b611d8f --- /dev/null +++ b/docs/contributor-guide/adr/0010-confirm-performance-issues-before-optimizing.md @@ -0,0 +1,24 @@ +# 10. Confirm performance issues before optimizing + +Date: 2021-02-04 + +## Status + +Accepted + +## Context + +There are multiple, equally-effective ways to implement many features. In some cases, the most +straightforward implementation might involve making more API calls than are strictly necessary +(e.g. `tc.dataset.create` makes an additional call to retrieve the created dataset from the server +to construct the returned `Dataset`). + +## Decision + +The simplest and most understandably-written implementation of a feature should be prioritized over +performance or reducing the number of API calls. When real performance issues are identified, +optimization should be done on an as-needed basis. + +## Consequences + +Functions will not be unnecessarily optimized at the cost of readability. \ No newline at end of file diff --git a/docs/contributor-guide/adrs.md b/docs/contributor-guide/adrs.md index ca8dabd5..aa5d2d72 100644 --- a/docs/contributor-guide/adrs.md +++ b/docs/contributor-guide/adrs.md @@ -19,4 +19,5 @@ To author new ADRs, we recommend [adr-tools](https://github.com/npryce/adr-tools * [Type checking](/contributor-guide/adr/0006-type-checking) * [tamr_client package](/contributor-guide/adr/0007-tamr-client-package) * [Standardized imports](/contributor-guide/adr/0008-standardized-imports) -* [Separate types and functions](/contributor-guide/adr/0009-separate-types-and-functions) \ No newline at end of file +* [Separate types and functions](/contributor-guide/adr/0009-separate-types-and-functions) +* [Confirm performance issues before optimizing](/contributor-guide/adr/0010-confirm-performance-issues-before-optimizing) \ No newline at end of file

5n0q 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 FO#qzqhCBcO From f8618e522a805530a3c3b2b2c3b5030d524f86c9 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 2 Oct 2019 21:49:47 -0400 Subject: [PATCH 182/632] fix(docs): update copyright for 2019 Previously said 2018 --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index dd3dca5e..02964fd4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,7 @@ # -- Project information ----------------------------------------------------- project = "Tamr - Python Client" -copyright = "2018, Tamr" +copyright = "2019, Tamr" author = "Tamr" From ef127a0048082b94c46bad783fa700588cb58356 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 3 Oct 2019 08:30:15 -0400 Subject: [PATCH 183/632] feat(docs): add and configure recommonmark recommonmark "allows you to write CommonMark inside of Docutils & Sphinx projects.". Note that CommonMark is a "strongly defined, highly compatible specification of Markdown". --- docs/conf.py | 22 ++++++++++++++++++++-- docs/requirements.txt | 1 + poetry.lock | 37 ++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 02964fd4..b13ab2b1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,6 +15,8 @@ import os import sys +import recommonmark +from recommonmark.transform import AutoStructify import toml sys.path.insert(0, os.path.abspath("..")) @@ -46,7 +48,12 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ["sphinx.ext.napoleon", "sphinx.ext.intersphinx", "sphinx.ext.viewcode"] +extensions = [ + "recommonmark", + "sphinx.ext.napoleon", + "sphinx.ext.intersphinx", + "sphinx.ext.viewcode", +] autodoc_default_flags = ["inherited-members", "members"] autodoc_member_order = "bysource" intersphinx_mapping = { @@ -62,7 +69,6 @@ # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = ".rst" # The master toctree document. master_doc = "index" @@ -195,3 +201,15 @@ # A list of files that should not be packed into the epub file. epub_exclude_files = ["search.html"] + + +def setup(app): + """ + https://recommonmark.readthedocs.io/en/latest/auto_structify.html#configuring-autostructify + """ + app.add_config_value( + "recommonmark_config", + {"enable_auto_toc_tree": True, "auto_toc_maxdepth": 2}, + True, + ) + app.add_transform(AutoStructify) diff --git a/docs/requirements.txt b/docs/requirements.txt index 7d42fd2e..68383c51 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,5 @@ # TODO(pcattori) Delete this file once RTD fully supports poetry +recommonmark Sphinx sphinx_rtd_theme toml diff --git a/poetry.lock b/poetry.lock index b7864835..14499467 100644 --- a/poetry.lock +++ b/poetry.lock @@ -88,6 +88,17 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "0.4.1" +[[package]] +category = "dev" +description = "Python parser for the CommonMark Markdown spec" +name = "commonmark" +optional = false +python-versions = "*" +version = "0.9.0" + +[package.dependencies] +future = "*" + [[package]] category = "dev" description = "Docutils -- Python Documentation Utilities" @@ -130,6 +141,14 @@ version = "0.18.1" pycodestyle = "*" setuptools = "*" +[[package]] +category = "dev" +description = "Clean single-source support for Python 3 and 2" +name = "future" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +version = "0.17.1" + [[package]] category = "main" description = "Internationalized Domain Names in Applications (IDNA)" @@ -319,6 +338,19 @@ optional = false python-versions = "*" version = "2019.1" +[[package]] +category = "dev" +description = "A docutils-compatibility bridge to CommonMark, enabling you to write CommonMark inside of Docutils & Sphinx projects." +name = "recommonmark" +optional = false +python-versions = "*" +version = "0.6.0" + +[package.dependencies] +commonmark = ">=0.8.1" +docutils = ">=0.11" +sphinx = ">=1.3.1" + [[package]] category = "main" description = "Python HTTP for Humans." @@ -488,7 +520,7 @@ python-versions = ">=2.7" version = "0.5.1" [metadata] -content-hash = "dfe6fe8cf933d15d1f203b1e457ec5110eeecff55a0c02394390ef2d693b235f" +content-hash = "7a311971be9bbe16602c09c6661d291a6b1d9ac9d6397203cfc42e6e2b5897c6" python-versions = "^3.6" [metadata.hashes] @@ -502,10 +534,12 @@ certifi = ["59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5", " chardet = ["84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", "fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"] click = ["2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", "5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"] colorama = ["05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", "f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"] +commonmark = ["14c3df31e8c9c463377e287b2a1eefaa6019ab97b22dad36e2f32be59d61d68d", "867fc5db078ede373ab811e16b6789e9d033b15ccd7296f370ca52d1ee792ce0"] docutils = ["02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6", "51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274", "7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6"] entrypoints = ["589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", "c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"] flake8 = ["859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661", "a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8"] flake8-import-order = ["90a80e46886259b9c396b578d75c749801a41ee969a235e163cfe1be7afd2543", "a28dc39545ea4606c1ac3c24e9d05c849c6e5444a50fb7e9cdd430fc94de6e92"] +future = ["67045236dcfd6816dc439556d009594abf643e5eb48992e36beac09c2ca659b8"] idna = ["c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", "ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"] imagesize = ["3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8", "f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5"] importlib-metadata = ["a9f185022cfa69e9ca5f7eabfd5a58b689894cb78a11e3c8c89398a8ccbb8e7f", "df1403cd3aebeb2b1dcd3515ca062eecb5bd3ea7611f18cba81130c68707e879"] @@ -525,6 +559,7 @@ pyparsing = ["1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a", pytest = ["6032845e68a17a96e8da3088037f899b56357769a724122056265ca2ea1890ee", "bea27a646a3d74cbbcf8d3d4a06b2dfc336baf3dc2cc85cf70ad0157e73e8322"] python-dateutil = ["7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb", "c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e"] pytz = ["303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda", "d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141"] +recommonmark = ["29cd4faeb6c5268c633634f2d69aef9431e0f4d347f90659fd0aab20e541efeb", "2ec4207a574289355d5b6ae4ae4abb29043346ca12cdd5f07d374dc5987d2852"] requests = ["11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", "9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"] responses = ["502d9c0c8008439cfcdef7e251f507fcfdd503b56e8c0c87c3c3e3393953f790", "97193c0183d63fba8cd3a041c75464e4b09ea0aff6328800d1546598567dde0b"] simplejson = ["067a7177ddfa32e1483ba5169ebea1bc2ea27f224853211ca669325648ca5642", "2b8cb601d9ba0381499db719ccc9dfbb2fbd16013f5ff096b1a68a4775576a04", "2c139daf167b96f21542248f8e0a06596c9b9a7a41c162cc5c9ee9f3833c93cd", "2fc546e6af49fb45b93bbe878dea4c48edc34083729c0abd09981fe55bdf7f91", "354fa32b02885e6dae925f1b5bbf842c333c1e11ea5453ddd67309dc31fdb40a", "37e685986cf6f8144607f90340cff72d36acf654f3653a6c47b84c5c38d00df7", "3af610ee72efbe644e19d5eaad575c73fb83026192114e5f6719f4901097fce2", "3b919fc9cf508f13b929a9b274c40786036b31ad28657819b3b9ba44ba651f50", "3dd289368bbd064974d9a5961101f080e939cbe051e6689a193c99fb6e9ac89b", "491de7acc423e871a814500eb2dcea8aa66c4a4b1b4825d18f756cdf58e370cb", "495511fe5f10ccf4e3ed4fc0c48318f533654db6c47ecbc970b4ed215c791968", "65b41a5cda006cfa7c66eabbcf96aa704a6be2a5856095b9e2fd8c293bad2b46", "6c3258ffff58712818a233b9737fe4be943d306c40cf63d14ddc82ba563f483a", "75e3f0b12c28945c08f54350d91e624f8dd580ab74fd4f1bbea54bc6b0165610", "79b129fe65fdf3765440f7a73edaffc89ae9e7885d4e2adafe6aa37913a00fbb", "b1f329139ba647a9548aa05fb95d046b4a677643070dc2afc05fa2e975d09ca5", "c206f47cbf9f32b573c9885f0ec813d2622976cf5effcf7e472344bc2e020ac1", "d8e238f20bcf70063ee8691d4a72162bcec1f4c38f83c93e6851e72ad545dabb", "ee9625fc8ee164902dfbb0ff932b26df112da9f871c32f0f9c1bcf20c350fe2a", "fb2530b53c28f0d4d84990e945c2ebb470edb469d63e389bf02ff409012fe7c5", "feadb95170e45f439455354904768608e356c5b174ca30b3d11b0e3f24b5c0df"] diff --git a/pyproject.toml b/pyproject.toml index fa7dc7f3..acbfc959 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ flake8 = "^3.7" toml = "^0.10.0" sphinx_rtd_theme = "^0.4.3" pandas = "^0.25.0" +recommonmark = "^0.6.0" [build-system] requires = ["poetry>=0.12"] From 9ab0f7e4b0a71df6d9fda4ea829ca1952ead93c5 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 3 Oct 2019 08:32:06 -0400 Subject: [PATCH 184/632] fix(docs): convert advanced usage guide to markdown --- docs/user-guide/advanced-usage.md | 143 ++++++++++++++++++++++++++++ docs/user-guide/advanced-usage.rst | 148 ----------------------------- 2 files changed, 143 insertions(+), 148 deletions(-) create mode 100644 docs/user-guide/advanced-usage.md delete mode 100644 docs/user-guide/advanced-usage.rst diff --git a/docs/user-guide/advanced-usage.md b/docs/user-guide/advanced-usage.md new file mode 100644 index 00000000..eaadf91c --- /dev/null +++ b/docs/user-guide/advanced-usage.md @@ -0,0 +1,143 @@ +# Advanced Usage + +## Asynchronous Operations + +You can opt-in to an asynchronous interface via the asynchronous keyword argument for methods that kick-off Tamr operations. + +E.g.: + +```python +operation = project.unified_dataset().refresh(asynchronous=True) +# do asynchronous stuff while operation is running +operation.wait() # hangs until operation finishes +assert op.succeeded() +``` + +## Logging API calls + +It can be useful (e.g. for debugging) to log the API calls made on your behalf by the Python Client. + +You can set up HTTP-API-call logging on any client via +standard [Python logging mechanisms](https://docs.python.org/3/library/logging.html): + +```python +from tamr_unify_client import Client +from tamr_unify_client import UsernamePasswordAuth +import logging + +auth = UsernamePasswordAuth("username", "password") +tamr = Client(auth) + +# Reload the `logging` library since other libraries (like `requests`) already +# configure logging differently. See: https://stackoverflow.com/a/53553516/1490091 +import imp +imp.reload(logging) + +logging.basicConfig( + level=logging.INFO, format="%(message)s", filename=log_path, filemode="w" +) +tamr.logger = logging.getLogger(name) +``` + +By default, when logging is set up, the client will log `{method} {url} : {response_status}` for each API call. + +You can customize this by passing in a value for `log_entry`: + +```python +def log_entry(method, url, response): +# custom logging function +# use the method, url, and response to construct the logged `str` +# e.g. for logging out machine-readable JSON: +import json +return json.dumps({ + "request": f"{method} {url}", + "status": response.status_code, + "json": response.json(), +}) + +# after configuring `tamr.logger` +tamr.log_entry = log_entry +``` + + +## Custom HTTP requests and Unversioned API Access + +We encourage you to use the high-level, object-oriented interface offered by the Python Client. If you aren't sure whether you need to send low-level HTTP requests, you probably don't. + +But sometimes it's useful to directly send HTTP requests to Tamr; for example, Tamr has many APIs that are not covered by the higher-level interface (most of which are neither versioned nor supported). You can still call these endpoints using the Python Client, but you'll need to work with raw `Response` objects. + +### Custom endpoint + +The client exposes a `request` method with the same interface as +`requests.request`: + +```python +# import Python Client library and configure your client + +tamr = Client(auth) +# do stuff with the `tamr` client + +# now I NEED to send a request to a specific endpoint +response = tamr.request('GET', 'relative/path/to/resource') +``` + +This will send a request relative to the base_path registered with the client. If you provide an absolute path to the resource, the base_path will be ignored when composing the request: + +```python +# import Python Client library and configure your client + +tamr = Client(auth) + +# request a resource outside the configured base_path +response = tamr.request('GET', '/absolute/path/to/resource') +``` + +You can also use the `get`, `post`, `put`, `delete` convenience +methods: + +```python +# e.g. `get` convenience method +response = tamr.get('relative/path/to/resource') +``` + +### Custom Host / Port / Base API path + +If you need to repeatedly send requests to another port or base API path (i.e. not `/api/versioned/v1/`), you can simply instantiate a different client. + +Then just call `request` as described above: + +```python +# import Python Client library and configure your client + +tamr = api.Client(auth) +# do stuff with the `tamr` client + +# now I NEED to send requests to a different host/port/base API path etc.. +# NOTE: in this example, we reuse `auth` from the first client, but we could +# have made a new Authentication provider if this client needs it. +custom_client = api.Client( + auth, + host="10.10.0.1", + port=9090, + base_path="/api/some_service/", +) +response = custom_client.get('relative/path/to/resource') +``` + +### One-off authenticated request + +All of the Python Client Authentication providers adhere to the `requests.auth.BaseAuth` interface. + +This means that you can pass in an Authentication provider directly to the `requests` library: + +```python +from tamr_unify_client.auth import UsernamePasswordAuth +import os +import requests + +username = os.environ['TAMR_USERNAME'] +password = os.environ['TAMR_PASSWORD'] +auth = UsernamePasswordAuth(username, password) + +response = requests.request('GET', 'some/specific/endpoint', auth=auth) +``` diff --git a/docs/user-guide/advanced-usage.rst b/docs/user-guide/advanced-usage.rst deleted file mode 100644 index cea04ac9..00000000 --- a/docs/user-guide/advanced-usage.rst +++ /dev/null @@ -1,148 +0,0 @@ -Advanced Usage -============== - -Asynchronous Operations ------------------------ - -You can opt-in to an asynchronous interface via the asynchronous keyword argument -for methods that kick-off Tamr operations. - -E.g.:: - - operation = project.unified_dataset().refresh(asynchronous=True) - # do asynchronous stuff while operation is running - operation.wait() # hangs until operation finishes - assert op.succeeded() - -Logging API calls ------------------ - -It can be useful (e.g. for debugging) to log the API calls made on your behalf -by the Python Client. - -You can set up HTTP-API-call logging on any client via -standard `Python logging mechanisms `_ :: - - from tamr_unify_client import Client - from tamr_unify_client import UsernamePasswordAuth - import logging - - auth = UsernamePasswordAuth("username", "password") - tamr = Client(auth) - - # Reload the `logging` library since other libraries (like `requests`) already - # configure logging differently. See: https://stackoverflow.com/a/53553516/1490091 - import imp - imp.reload(logging) - - logging.basicConfig( - level=logging.INFO, format="%(message)s", filename=log_path, filemode="w" - ) - tamr.logger = logging.getLogger(name) - -By default, when logging is set up, the client will log ``{method} {url} : -{response_status}`` for each API call. - -You can customize this by passing in a value for ``log_entry``:: - - def log_entry(method, url, response): - # custom logging function - # use the method, url, and response to construct the logged `str` - # e.g. for logging out machine-readable JSON: - import json - return json.dumps({ - "request": f"{method} {url}", - "status": response.status_code, - "json": response.json(), - }) - - # after configuring `tamr.logger` - tamr.log_entry = log_entry - -.. _custom-http-requests-and-unversioned-api-access: - -Custom HTTP requests and Unversioned API Access ------------------------------------------------ - -We encourage you to use the high-level, object-oriented interface offered by -the Python Client. If you aren't sure whether you need to send low-level HTTP -requests, you probably don't. - -But sometimes it's useful to directly send HTTP requests to Tamr; for example, -Tamr has many APIs that are not covered by the higher-level interface (most of -which are neither versioned nor supported). You can still call these endpoints -using the Python Client, but you'll need to work with raw ``Response`` objects. - -Custom endpoint -^^^^^^^^^^^^^^^ - -The client exposes a ``request`` method with the same interface as -``requests.request``:: - - # import Python Client library and configure your client - - tamr = Client(auth) - # do stuff with the `tamr` client - - # now I NEED to send a request to a specific endpoint - response = tamr.request('GET', 'relative/path/to/resource') - -This will send a request relative to the base_path registered with the client. -If you provide an absolute path to the resource, the base_path will be ignored -when composing the request:: - - # import Python Client library and configure your client - - tamr = Client(auth) - - # request a resource outside the configured base_path - response = tamr.request('GET', '/absolute/path/to/resource') - -You can also use the ``get``, ``post``, ``put``, ``delete`` convenience -methods:: - - # e.g. `get` convenience method - response = tamr.get('relative/path/to/resource') - -Custom Host / Port / Base API path -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -If you need to repeatedly send requests to another port or base API path -(i.e. not ``/api/versioned/v1/``), you can simply instantiate a different client. - -Then just call ``request`` as described above:: - - # import Python Client library and configure your client - - tamr = api.Client(auth) - # do stuff with the `tamr` client - - # now I NEED to send requests to a different host/port/base API path etc.. - # NOTE: in this example, we reuse `auth` from the first client, but we could - # have made a new Authentication provider if this client needs it. - custom_client = api.Client( - auth, - host="10.10.0.1", - port=9090, - base_path="/api/some_service/", - ) - response = custom_client.get('relative/path/to/resource') - -One-off authenticated request -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -All of the Python Client Authentication providers adhere to the -``requests.auth.BaseAuth`` interface. - -This means that you can pass in an -Authentication provider directly to the ``requests`` library:: - - from tamr_unify_client.auth import UsernamePasswordAuth - import os - import requests - - username = os.environ['TAMR_USERNAME'] - password = os.environ['TAMR_PASSWORD'] - auth = UsernamePasswordAuth(username, password) - - response = requests.request('GET', 'some/specific/endpoint', auth=auth) \ No newline at end of file From 646e436a7352c45b39baaa1c5d80a270e5aa0f96 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 3 Oct 2019 09:05:44 -0400 Subject: [PATCH 185/632] fix(docs): convert index to markdown --- docs/index.md | 45 ++++++++++++++++++++++++++++++++++++++ docs/index.rst | 59 -------------------------------------------------- 2 files changed, 45 insertions(+), 59 deletions(-) create mode 100644 docs/index.md delete mode 100644 docs/index.rst diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..81f371c3 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,45 @@ +# Tamr - Python Client + +[View on Github](https://github.com/Datatamer/tamr-client) + +## Example + +```python +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth +import os + +# grab credentials from environment variables +username = os.environ['TAMR_USERNAME'] +password = os.environ['TAMR_PASSWORD'] +auth = UsernamePasswordAuth(username, password) + +host = 'localhost' # replace with your Tamr host +tamr = Client(auth, host=host) + +# programmatically interace with Tamr! +# e.g. refresh your project's Unified Dataset +project = tamr.projects.by_resource_id('3') +ud = project.unified_dataset() +op = ud.refresh() +assert op.succeeded() +``` + +## User Guide + + * [FAQ](user-guide/faq) + * [Install](user-guide/installation) + * [Quickstart](user-guide/quickstart) + * [Secure credentials](user-guide/secure-credentials) + * [Workflows](user-guide/workflows) + * [Create and update resources](user-guide/spec) + * [Geospatial data](user-guide/geo) + * [Advanced usage](user-guide/advanced-usage) + +## Contributor Guide + + * [Contributor guide](contributor-guide) + +## Developer Interface + + * [Developer interface](developer-interface) diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 7725cd4e..00000000 --- a/docs/index.rst +++ /dev/null @@ -1,59 +0,0 @@ -Tamr - Python Client -========================== - -Version: |release| | `View on Github `_ - -Example -------- - -:: - - from tamr_unify_client import Client - from tamr_unify_client.auth import UsernamePasswordAuth - import os - - # grab credentials from environment variables - username = os.environ['TAMR_USERNAME'] - password = os.environ['TAMR_PASSWORD'] - auth = UsernamePasswordAuth(username, password) - - host = 'localhost' # replace with your Tamr host - tamr = Client(auth, host=host) - - # programmatically interace with Tamr! - # e.g. refresh your project's Unified Dataset - project = tamr.projects.by_resource_id('3') - ud = project.unified_dataset() - op = ud.refresh() - assert op.succeeded() - -User Guide ----------- - -.. toctree:: - :maxdepth: 2 - - user-guide/faq - user-guide/installation - user-guide/quickstart - user-guide/secure-credentials - user-guide/workflows - user-guide/spec - user-guide/geo - user-guide/advanced-usage - -Contributor Guide ------------------ - -.. toctree:: - :maxdepth: 2 - - contributor-guide - -Developer Interface -------------------- - -.. toctree:: - :maxdepth: 2 - - developer-interface From 56467a8d45bde5c89998d5e493d702ae42490f2c Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 7 Oct 2019 14:44:54 -0400 Subject: [PATCH 186/632] fix(docs): typo "interace" -> "interact" --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 81f371c3..9ad61c6f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,7 +17,7 @@ auth = UsernamePasswordAuth(username, password) host = 'localhost' # replace with your Tamr host tamr = Client(auth, host=host) -# programmatically interace with Tamr! +# programmatically interact with Tamr! # e.g. refresh your project's Unified Dataset project = tamr.projects.by_resource_id('3') ud = project.unified_dataset() From 008241fd7be29d58e1a098504d4695aa054df588 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 7 Oct 2019 14:45:18 -0400 Subject: [PATCH 187/632] fix(docs): operations are not refreshed in-place. Instead use the returned operation when calling operation methods. --- docs/user-guide/advanced-usage.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/user-guide/advanced-usage.md b/docs/user-guide/advanced-usage.md index eaadf91c..1583bd0d 100644 --- a/docs/user-guide/advanced-usage.md +++ b/docs/user-guide/advanced-usage.md @@ -7,9 +7,9 @@ You can opt-in to an asynchronous interface via the asynchronous keyword argumen E.g.: ```python -operation = project.unified_dataset().refresh(asynchronous=True) -# do asynchronous stuff while operation is running -operation.wait() # hangs until operation finishes +op = project.unified_dataset().refresh(asynchronous=True) +# do asynchronous stuff here while operation is running +op = op.wait() # hangs until operation finishes assert op.succeeded() ``` From 3620c0945569df70469bb7891d5fc000d9dc187a Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 7 Oct 2019 15:27:57 -0400 Subject: [PATCH 188/632] empty __init__.py not required for Python 3.3+ --- tamr_unify_client/attribute/__init__.py | 0 tamr_unify_client/categorization/__init__.py | 0 tamr_unify_client/categorization/category/__init__.py | 0 tamr_unify_client/dataset/__init__.py | 0 tamr_unify_client/mastering/__init__.py | 0 tamr_unify_client/mastering/published_cluster/__init__.py | 0 tamr_unify_client/project/__init__.py | 0 tamr_unify_client/project/attribute_configuration/__init__.py | 0 tamr_unify_client/project/attribute_mapping/__init__.py | 0 9 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 tamr_unify_client/attribute/__init__.py delete mode 100644 tamr_unify_client/categorization/__init__.py delete mode 100644 tamr_unify_client/categorization/category/__init__.py delete mode 100644 tamr_unify_client/dataset/__init__.py delete mode 100644 tamr_unify_client/mastering/__init__.py delete mode 100644 tamr_unify_client/mastering/published_cluster/__init__.py delete mode 100644 tamr_unify_client/project/__init__.py delete mode 100644 tamr_unify_client/project/attribute_configuration/__init__.py delete mode 100644 tamr_unify_client/project/attribute_mapping/__init__.py diff --git a/tamr_unify_client/attribute/__init__.py b/tamr_unify_client/attribute/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tamr_unify_client/categorization/__init__.py b/tamr_unify_client/categorization/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tamr_unify_client/categorization/category/__init__.py b/tamr_unify_client/categorization/category/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tamr_unify_client/dataset/__init__.py b/tamr_unify_client/dataset/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tamr_unify_client/mastering/__init__.py b/tamr_unify_client/mastering/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tamr_unify_client/mastering/published_cluster/__init__.py b/tamr_unify_client/mastering/published_cluster/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tamr_unify_client/project/__init__.py b/tamr_unify_client/project/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tamr_unify_client/project/attribute_configuration/__init__.py b/tamr_unify_client/project/attribute_configuration/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tamr_unify_client/project/attribute_mapping/__init__.py b/tamr_unify_client/project/attribute_mapping/__init__.py deleted file mode 100644 index e69de29b..00000000 From 34d065d98b260d1b3d3c40844febf2ca1ca4011e Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 1 Nov 2019 13:22:06 -0400 Subject: [PATCH 189/632] Freeze doc requirements to their current poetry.lock versions recommonmark's AutoStructify was producing a ToC correctly locally, but not remotely on RTD. After some investigation, I found that RTD was using different versions of doc dependencies (most notably Sphinx <2 on RTD, whereas locally we are using 2.1.0). Hopefully, freezing the doc dependencies to be consistent with local version should fix the ToC rendering remotely. --- docs/requirements.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 68383c51..3aea0bc4 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,6 @@ # TODO(pcattori) Delete this file once RTD fully supports poetry -recommonmark -Sphinx -sphinx_rtd_theme -toml +recommonmark==0.6.0 +Sphinx==2.1.0 +sphinx_rtd_theme==0.4.3 +toml==0.10.0 . From 4b5f99524981a4547c39f7bb47b8de750eeaaa66 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 9 Oct 2019 22:07:21 -0400 Subject: [PATCH 190/632] POC for dataclasses --- tamr_unify_client/attribute/subattribute.py | 69 ++++++++++----------- tamr_unify_client/attribute/type.py | 5 +- 2 files changed, 34 insertions(+), 40 deletions(-) diff --git a/tamr_unify_client/attribute/subattribute.py b/tamr_unify_client/attribute/subattribute.py index 0f0665b5..d2279c5e 100644 --- a/tamr_unify_client/attribute/subattribute.py +++ b/tamr_unify_client/attribute/subattribute.py @@ -1,43 +1,38 @@ +from copy import deepcopy +from dataclasses import dataclass, field +from typing import Any, Dict, Optional + +from tamr_unify_client.attribute.type import AttributeType + +SubAttributeJson = Dict[str, Any] + +@dataclass(frozen=True) class SubAttribute: - """ - An attribute which is itself a property of another attribute. + """An attribute which is itself a property of another attribute. See https://docs.tamr.com/reference#attribute-types - :param data: JSON data representing this attribute - :type data: :py:class:`dict` + Args: + name: Name of sub-attribute + description: Description of sub-attribute + type: See https://docs.tamr.com/reference#attribute-types + is_nullable: If this sub-attribute can be null """ + name: str + type: AttributeType + is_nullable: bool + _json: SubAttributeJson = field(repr=False) + description: Optional[str] = None + + @staticmethod + def from_json(d: SubAttributeJson) -> 'SubAttribute': + _json = deepcopy(d) + + dc = deepcopy(d) + dc['is_nullable'] = dc.pop('isNullable') + + type_json = dc.pop('type') + # TODO implement AttributeType.from_json and use that instead + type = AttributeType(type_json) - def __init__(self, data): - self._data = data - - @property - def name(self): - """:type: str""" - return self._data.get("name") - - @property - def description(self): - """:type: str""" - return self._data.get("description") - - @property - def type(self): - """:type: :class:`~tamr_unify_client.attribute.type.AttributeType`""" - # import locally to avoid circular dependency - from tamr_unify_client.attribute.type import AttributeType - - type_json = self._data.get("type") - return AttributeType(type_json) - - @property - def is_nullable(self): - """:type: bool""" - return self._data.get("isNullable") - - def __repr__(self): - return ( - f"{self.__class__.__module__}." - f"{self.__class__.__qualname__}(" - f"name={self.name!r})" - ) + return SubAttribute(**dc, type=type, _json=_json) diff --git a/tamr_unify_client/attribute/type.py b/tamr_unify_client/attribute/type.py index dd0f28b9..2a94a1a1 100644 --- a/tamr_unify_client/attribute/type.py +++ b/tamr_unify_client/attribute/type.py @@ -1,7 +1,5 @@ from copy import deepcopy -from tamr_unify_client.attribute.subattribute import SubAttribute - class AttributeType: """ @@ -32,8 +30,9 @@ def inner_type(self): @property def attributes(self): """:type: list[:class:`~tamr_unify_client.attribute.subattribute.SubAttribute`]""" + from tamr_unify_client.attribute.subattribute import SubAttribute collection_json = self._data.get("attributes") - return [SubAttribute(attr) for attr in collection_json] + return [SubAttribute.from_json(attr) for attr in collection_json] def spec(self): """Returns a spec representation of this attribute type. From e1f9e4930d4f2f0080bdae63c329c853d9bd7148 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 10 Oct 2019 10:32:29 -0400 Subject: [PATCH 191/632] fix: Add dataclasses backport for 3.6 fix(docs): Add sphinx-autodoc-typehints to inline arg types from type annotations --- docs/conf.py | 1 + poetry.lock | 23 ++++++++++++++++++++++- pyproject.toml | 2 ++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index b13ab2b1..e2fea855 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -51,6 +51,7 @@ extensions = [ "recommonmark", "sphinx.ext.napoleon", + "sphinx_autodoc_typehints", "sphinx.ext.intersphinx", "sphinx.ext.viewcode", ] diff --git a/poetry.lock b/poetry.lock index 14499467..23b7c9b7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -99,6 +99,14 @@ version = "0.9.0" [package.dependencies] future = "*" +[[package]] +category = "main" +description = "A backport of the dataclasses module for Python 3.6" +name = "dataclasses" +optional = false +python-versions = "*" +version = "0.6" + [[package]] category = "dev" description = "Docutils -- Python Documentation Utilities" @@ -428,6 +436,17 @@ sphinxcontrib-jsmath = "*" sphinxcontrib-qthelp = "*" sphinxcontrib-serializinghtml = "*" +[[package]] +category = "dev" +description = "Type hints (PEP 484) support for the Sphinx autodoc extension" +name = "sphinx-autodoc-typehints" +optional = false +python-versions = ">=3.5.2" +version = "1.8.0" + +[package.dependencies] +Sphinx = ">=2.1" + [[package]] category = "dev" description = "Read the Docs theme for Sphinx" @@ -520,7 +539,7 @@ python-versions = ">=2.7" version = "0.5.1" [metadata] -content-hash = "7a311971be9bbe16602c09c6661d291a6b1d9ac9d6397203cfc42e6e2b5897c6" +content-hash = "c6299c62f796446ea730148a7dbfe97254f32365337ba10eac0be0326f9f684e" python-versions = "^3.6" [metadata.hashes] @@ -535,6 +554,7 @@ chardet = ["84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", " click = ["2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", "5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"] colorama = ["05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", "f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"] commonmark = ["14c3df31e8c9c463377e287b2a1eefaa6019ab97b22dad36e2f32be59d61d68d", "867fc5db078ede373ab811e16b6789e9d033b15ccd7296f370ca52d1ee792ce0"] +dataclasses = ["454a69d788c7fda44efd71e259be79577822f5e3f53f029a22d08004e951dc9f", "6988bd2b895eef432d562370bb707d540f32f7360ab13da45340101bc2307d84"] docutils = ["02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6", "51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274", "7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6"] entrypoints = ["589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", "c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"] flake8 = ["859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661", "a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8"] @@ -566,6 +586,7 @@ simplejson = ["067a7177ddfa32e1483ba5169ebea1bc2ea27f224853211ca669325648ca5642" six = ["3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", "d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"] snowballstemmer = ["919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128", "9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89"] sphinx = ["2c5becc0fd6706dc0aeb4703f9f1f8a1d1eecacf02e9ac5943cbae48b11e5e42", "7a359a91fb04054ec77d68ff97cb8728f8cc322e25f22dc94299d67e0e6a7123"] +sphinx-autodoc-typehints = ["0d968ec3ee4f7fe7695ab6facf5cd2d74d3cea67584277458ad9b2788ebbcc3b", "8edca714fd3de8e43467d7e51dd3812fe999f8874408a639f7c38a9e1a5a4eb3"] sphinx-rtd-theme = ["00cf895504a7895ee433807c62094cf1e95f065843bf3acd17037c3e9a2becd4", "728607e34d60456d736cc7991fd236afb828b21b82f956c5ea75f94c8414040a"] sphinxcontrib-applehelp = ["edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897", "fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d"] sphinxcontrib-devhelp = ["6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34", "9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981"] diff --git a/pyproject.toml b/pyproject.toml index acbfc959..a62f3880 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ python = "^3.6" requests = "^2.22" simplejson = "^3.16" +dataclasses = "^0.6.0" [tool.poetry.dev-dependencies] Sphinx = "^2.1" @@ -36,6 +37,7 @@ toml = "^0.10.0" sphinx_rtd_theme = "^0.4.3" pandas = "^0.25.0" recommonmark = "^0.6.0" +sphinx-autodoc-typehints = "^1.8" [build-system] requires = ["poetry>=0.12"] From 2c0924b671abd9f44290f124c2e5fdc960eef8d7 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 10 Oct 2019 10:41:24 -0400 Subject: [PATCH 192/632] fix: formatting --- tamr_unify_client/attribute/subattribute.py | 8 +++++--- tamr_unify_client/attribute/type.py | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tamr_unify_client/attribute/subattribute.py b/tamr_unify_client/attribute/subattribute.py index d2279c5e..d9f11859 100644 --- a/tamr_unify_client/attribute/subattribute.py +++ b/tamr_unify_client/attribute/subattribute.py @@ -6,6 +6,7 @@ SubAttributeJson = Dict[str, Any] + @dataclass(frozen=True) class SubAttribute: """An attribute which is itself a property of another attribute. @@ -18,6 +19,7 @@ class SubAttribute: type: See https://docs.tamr.com/reference#attribute-types is_nullable: If this sub-attribute can be null """ + name: str type: AttributeType is_nullable: bool @@ -25,13 +27,13 @@ class SubAttribute: description: Optional[str] = None @staticmethod - def from_json(d: SubAttributeJson) -> 'SubAttribute': + def from_json(d: SubAttributeJson) -> "SubAttribute": _json = deepcopy(d) dc = deepcopy(d) - dc['is_nullable'] = dc.pop('isNullable') + dc["is_nullable"] = dc.pop("isNullable") - type_json = dc.pop('type') + type_json = dc.pop("type") # TODO implement AttributeType.from_json and use that instead type = AttributeType(type_json) diff --git a/tamr_unify_client/attribute/type.py b/tamr_unify_client/attribute/type.py index 2a94a1a1..2bf37fe8 100644 --- a/tamr_unify_client/attribute/type.py +++ b/tamr_unify_client/attribute/type.py @@ -31,6 +31,7 @@ def inner_type(self): def attributes(self): """:type: list[:class:`~tamr_unify_client.attribute.subattribute.SubAttribute`]""" from tamr_unify_client.attribute.subattribute import SubAttribute + collection_json = self._data.get("attributes") return [SubAttribute.from_json(attr) for attr in collection_json] From f87b2c0af2bbeca0f6afb7e87ef2e1aa8ee7a8db Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 10 Oct 2019 10:52:38 -0400 Subject: [PATCH 193/632] fix(docs): docstring for `from_json` --- tamr_unify_client/attribute/subattribute.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tamr_unify_client/attribute/subattribute.py b/tamr_unify_client/attribute/subattribute.py index d9f11859..2377c89f 100644 --- a/tamr_unify_client/attribute/subattribute.py +++ b/tamr_unify_client/attribute/subattribute.py @@ -27,10 +27,15 @@ class SubAttribute: description: Optional[str] = None @staticmethod - def from_json(d: SubAttributeJson) -> "SubAttribute": - _json = deepcopy(d) + def from_json(data: SubAttributeJson) -> "SubAttribute": + """Create a SubAttribute from JSON data. - dc = deepcopy(d) + Args: + data: JSON data received from Tamr server. + """ + _json = deepcopy(data) + + dc = deepcopy(data) dc["is_nullable"] = dc.pop("isNullable") type_json = dc.pop("type") From 0812586dc3d4aa0887f119c85ad279963c694a80 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 1 Nov 2019 10:19:31 -0400 Subject: [PATCH 194/632] Remove unused Pull Request templates Github does not yet support using Pull Request templates from its UI. This change is also done opportunistically as part of PR#303 to trigger CI, since Travis seems to be stuck and not reporting success for last commit. --- .github/PULL_REQUEST_TEMPLATE/BUG_FIX.md | 44 -------------------- .github/PULL_REQUEST_TEMPLATE/NEW_FEATURE.md | 41 ------------------ 2 files changed, 85 deletions(-) delete mode 100644 .github/PULL_REQUEST_TEMPLATE/BUG_FIX.md delete mode 100644 .github/PULL_REQUEST_TEMPLATE/NEW_FEATURE.md diff --git a/.github/PULL_REQUEST_TEMPLATE/BUG_FIX.md b/.github/PULL_REQUEST_TEMPLATE/BUG_FIX.md deleted file mode 100644 index 2aa99d02..00000000 --- a/.github/PULL_REQUEST_TEMPLATE/BUG_FIX.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -name: 🐛 Bug Fix -about: Did you fix something that did not work as expected? ---- - - - -# 🐛 bug fix - - - -## 🤔 Tell Us What Goes Wrong - - - -## 💁 Your Solution - - - -## 🚨 Test instructions - - - -## 🌍 Your Environment - - - -| Software | Version(s) | -| ----------------- | ---------- | -| Python | -| tamr-unify-client | -| Operating System | - -## ✔️ PR Todo - -- [ ] Added/updated unit tests for this change -- [ ] Filled out test instructions (In case there aren't any unit tests) -- [ ] Included links to related issues/PRs -- [ ] Update relevant docs + docstrings -- [ ] Add a "Bug Fixes" entry for the current development version in the changelog diff --git a/.github/PULL_REQUEST_TEMPLATE/NEW_FEATURE.md b/.github/PULL_REQUEST_TEMPLATE/NEW_FEATURE.md deleted file mode 100644 index b854a3a0..00000000 --- a/.github/PULL_REQUEST_TEMPLATE/NEW_FEATURE.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -name: 🙋 New Feature -about: Do you want to add something to tamr-unify-client? ---- - - - -# ✨ New Feature - - - -## 🔦 Context - - - - - -## 💻 Examples - - - -## 🚨 Test instructions - - - -## ✔️ PR Todo - -- [ ] Added/updated unit tests for this change -- [ ] Filled out test instructions (In case there aren't any unit tests) -- [ ] Included links to related issues/PRs -- [ ] Update relevant docs + docstrings -- [ ] Add an "NEW FEATURES" entry for the current development version in the changelog From f30e980fdf84ff7598e6c9af2f735a2e65afb2ef Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 1 Nov 2019 13:38:46 -0400 Subject: [PATCH 195/632] Add sphinx-autodoc-typehints to doc dependencies Also sort doc dependencies --- docs/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 3aea0bc4..fbb0b9b9 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,7 @@ # TODO(pcattori) Delete this file once RTD fully supports poetry recommonmark==0.6.0 -Sphinx==2.1.0 sphinx_rtd_theme==0.4.3 +sphinx-autodoc-typehints==1.8.0 +Sphinx==2.1.0 toml==0.10.0 . From 9f2ed3904eac6ed1d50282c6fa83a68345b0dc93 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 1 Nov 2019 13:49:26 -0400 Subject: [PATCH 196/632] Changelog entry for SubAttribute as a dataclass --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1190181f..42d633bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,7 @@ ## 0.10.0-dev + **BREAKING CHANGES** + - [#309](https://github.com/Datatamer/tamr-client/issues/309) Migrate `SubAttribute` to use `@dataclass(frozen=True)`. `SubAttribute.__init__` constructor replaced with the one generated by `@dataclass`. `SubAttribute`s should be constructed via the `SubAttribute.from_json` static method. + **BUG FIXES** - [#293](https://github.com/Datatamer/tamr-client/issues/293) Better handling for HTTP 204 on already up-to-date operations From 3cc7c4a421af68b4ea78290810c5f8b843829892 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 4 Nov 2019 16:21:59 -0500 Subject: [PATCH 197/632] Treat sphinx-build warnings as errors in CI --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 9b34a743..770ed84c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,6 +10,7 @@ script: - poetry run black --check . - poetry run flake8 . - poetry run pytest tests +- cd docs && poetry run sphinx-build -b html . _build -W before_deploy: - poetry build - poetry config http-basic.pypi $PYPI_USERNAME $PYPI_PASSWORD From 6dfa4d4262e25715691cbcdb1cf5c8278e3de9db Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 5 Nov 2019 14:18:26 -0500 Subject: [PATCH 198/632] fix(docs): fix broken links/references ...caused by advanced-usage conversion from .rst to .md --- docs/conf.py | 2 + docs/user-guide/advanced-usage.md | 2 +- docs/user-guide/faq.md | 24 ++++++++ docs/user-guide/faq.rst | 91 ------------------------------- 4 files changed, 27 insertions(+), 92 deletions(-) create mode 100644 docs/user-guide/faq.md delete mode 100644 docs/user-guide/faq.rst diff --git a/docs/conf.py b/docs/conf.py index e2fea855..df8ea1f8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -54,9 +54,11 @@ "sphinx_autodoc_typehints", "sphinx.ext.intersphinx", "sphinx.ext.viewcode", + "sphinx.ext.autosectionlabel", ] autodoc_default_flags = ["inherited-members", "members"] autodoc_member_order = "bysource" +autosectionlabel_prefix_document = True intersphinx_mapping = { "https://docs.python.org/": None, "requests": ("https://requests.kennethreitz.org/en/master/", None), diff --git a/docs/user-guide/advanced-usage.md b/docs/user-guide/advanced-usage.md index 1583bd0d..07c999fa 100644 --- a/docs/user-guide/advanced-usage.md +++ b/docs/user-guide/advanced-usage.md @@ -60,7 +60,7 @@ tamr.log_entry = log_entry ``` -## Custom HTTP requests and Unversioned API Access +## Raw HTTP requests and Unversioned API Access We encourage you to use the high-level, object-oriented interface offered by the Python Client. If you aren't sure whether you need to send low-level HTTP requests, you probably don't. diff --git a/docs/user-guide/faq.md b/docs/user-guide/faq.md new file mode 100644 index 00000000..8243ffc3 --- /dev/null +++ b/docs/user-guide/faq.md @@ -0,0 +1,24 @@ +# FAQ + +## What version of the Python Client should I use? + +The Python Client just cares about features, and will try everything it knows to implement those features correctly, independent of the API version. + +If you are starting a new project or your existing project does not yet use the Python Client, we encourage you to use the **latest stable version** of the Python Client. + +Otherwise, check the [Change Log](https://github.com/Datatamer/tamr-client/blob/master/CHANGELOG.md) to see: + +* what new features and bug fixes are available in newer versions +* which breaking changes (if any) will require changes in your code to get those new features and bug fixes + +Note: You do not need to reason about the Tamr API version nor the the Tamr app/server version. + +## How do I call custom endpoints, e.g. endpoints outside the Tamr API? + +To call a custom endpoint *within* the Tamr API, use the `client.request()` method, and provide an endpoint described by a path relative to `base_path`. + +For example, if `base_path` is `/api/versioned/v1/` (the default), and you want to get `/api/versioned/v1/projects/1`, you only need to provide `projects/1` (the relative ID provided by the project) as the endpoint, and the Client will resolve that into `/api/versioned/v1/projects/1`. + +There are various APIs outside the `/api/versioned/v1/` prefix that are often useful or necessary to call - e.g. `/api/service/health`, or other un-versioned / unsupported APIs. To call a custom endpoint *outside* the Tamr API, use the `client.request()` method, and provide an endpoint described by an *absolute* path (a path starting with `/`). For example, to get `/api/service/health` (no matter what `base_path` is), call `client.request()` with `/api/service/health` as the endpoint. The Client will ignore `base_path` and send the request directly against the absolute path provided. + +For additional detail, see [Raw HTTP requests and Unversioned API Access]() diff --git a/docs/user-guide/faq.rst b/docs/user-guide/faq.rst deleted file mode 100644 index 3ecbf5f2..00000000 --- a/docs/user-guide/faq.rst +++ /dev/null @@ -1,91 +0,0 @@ -FAQ -=== - -What version of the Python Client should I use? ------------------------------------------------ - -If you are starting a new project or your existing project does not yet use the -Python Client, we encourage you to use the **latest stable version** of the Python -Client. - ----- - -If you are already using the Python Client, you have 3 options: - - -1. **"I like my project's code the way it is."** - - Keep using the version you are on. - -2. **"I want some new features released in versions with the same major version that I'm currently using."** - - Upgrade to the latest stable version *with the same major version* as what - you currently use. - -3. **"I want all new features and I'm willing to modify my code to get those features!"** - - Upgrade to the latest stable version *even* if it has a different major - version from what you currently use. - -Note that you do not need to reason about the Tamr API version nor the the Tamr version. - ----- - -**How does this the Python Client accomplish this?** - -The short answer is that the Python Client just cares about features, and will -try everything it knows to implement those features correctly, independent of -the API version. - -We'll illustrate with an example. - -Let's say you want to get a dataset by name in your Python code. - -**1.** If no such feature exists, you can file a Feature Request. Note that the Python -Client is limited by what the Tamr API enables. So you should check if the Tamr -API docs to see if the feature you want is even possible. - -**2.** If this feature already exists, you can try it out! - -E.g. ``tamr.datasets.by_name(some_dataset_name)`` - - **2.a** It works! 🎉 - - **2.b** If it fails with an HTTP error, it could be for 2 reasons: - - **2.a.i** It might be impossible to support that feature in the Python Client - because your Tamr API version does not have the necessary endpoints to - support it. - - **2.a.ii** Your Tamr API version *does* support this feature with some endpoints, - but the Python Client know how to correctly implement this feature for this - version of the API. In this case, you should submit a Feature Request. - - **2.c** If it fails with any other error, you should submit a Bug Report. 🐛 - - -.. note:: - To see how to submit Bug Reports / Feature Requests, see :ref:`bug-reports-feature-requests`. - - To check what endpoints your version of the Tamr API supports, see `docs.tamr.com/reference `_ - (be sure to select the correct version in the top left!). - - -How do I call custom endpoints, e.g. endpoints outside the Tamr API? ---------------------------------------------------------------------- - -To call a custom endpoint *within* the Tamr API, use the ``client.request()`` method, and -provide an endpoint described by a path relative to ``base_path``. For example, if ``base_path`` -is ``/api/versioned/v1/`` (the default), and you want to get ``/api/versioned/v1/projects/1``, -you only need to provide ``projects/1`` (the relative ID provided by the project) as the endpoint, -and the Client will resolve that into ``/api/versioned/v1/projects/1``. - -There are various APIs outside the ``/api/versioned/v1/`` prefix that are often useful or necessary -to call - e.g. ``/api/service/health``, or other un-versioned / unsupported APIs. To call a custom -endpoint *outside* the Tamr API, use the ``client.request()`` method, and provide an endpoint -described by an *absolute* path (a path starting with ``/``). For example, to get -``/api/service/health`` (no matter what ``base_path`` is), call ``client.request()`` with -``/api/service/health`` as the endpoint. The Client will ignore ``base_path`` and send the -request directly against the absolute path provided. - -For additional detail, see :ref:`custom-http-requests-and-unversioned-api-access`. From 05e6eb154bd17cdd5d7c0f9af521b1feb75f9c15 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 5 Nov 2019 14:19:18 -0500 Subject: [PATCH 199/632] fix(docs): duplicate labels in developer interface docs changed to be unique --- docs/developer-interface.rst | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst index 05d2f9c4..54c7b697 100644 --- a/docs/developer-interface.rst +++ b/docs/developer-interface.rst @@ -13,8 +13,8 @@ Client .. autoclass:: tamr_unify_client.Client :members: -Attribute ---------- +Attributes +---------- Attribute ^^^^^^^^^ @@ -60,8 +60,8 @@ Categorization Project .. autoclass:: tamr_unify_client.categorization.project.CategorizationProject :members: -Category -^^^^^^^^ +Categories +^^^^^^^^^^ Category """""""" @@ -87,8 +87,8 @@ Taxonomy .. autoclass:: tamr_unify_client.categorization.taxonomy.Taxonomy :members: -Dataset -------- +Datasets +-------- Dataset ^^^^^^^ @@ -167,8 +167,8 @@ Mastering Project .. autoclass:: tamr_unify_client.mastering.project.MasteringProject :members: -Published Cluster -^^^^^^^^^^^^^^^^^ +Published Clusters +^^^^^^^^^^^^^^^^^^ Metric """""" @@ -212,11 +212,11 @@ Operation :members: -Project -------- +Projects +-------- -Attribute Configuration -^^^^^^^^^^^^^^^^^^^^^^^ +Attribute Configurations +^^^^^^^^^^^^^^^^^^^^^^^^ Attribute Configuration """"""""""""""""""""""" @@ -237,8 +237,8 @@ Attribute Configuration Collection :members: -Attribute Mapping -^^^^^^^^^^^^^^^^^ +Attribute Mappings +^^^^^^^^^^^^^^^^^^ Attribute Mapping """"""""""""""""" From 645bc820febaba45ef56b86320bd366afabc1c96 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 19 Dec 2019 13:17:28 -0500 Subject: [PATCH 200/632] Logging revamp Log response body for requests with HTTP error codes (4xx, 5xx) Adhere to logging best practices as defined in https://docs.python-guide.org/writing/logging/#logging-in-a-library Also: - add some type annotations to client.py - reformat some docstrings to Google-style --- tamr_unify_client/__init__.py | 5 ++ tamr_unify_client/client.py | 104 +++++++++++++++++----------------- 2 files changed, 58 insertions(+), 51 deletions(-) diff --git a/tamr_unify_client/__init__.py b/tamr_unify_client/__init__.py index b556f056..b84da087 100644 --- a/tamr_unify_client/__init__.py +++ b/tamr_unify_client/__init__.py @@ -1 +1,6 @@ +import logging + from tamr_unify_client.client import Client + +# https://docs.python-guide.org/writing/logging/#logging-in-a-library +logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/tamr_unify_client/client.py b/tamr_unify_client/client.py index 3d0d6da6..a71b116a 100644 --- a/tamr_unify_client/client.py +++ b/tamr_unify_client/client.py @@ -1,62 +1,76 @@ +import logging +from typing import Optional from urllib.parse import urljoin import requests -from requests import Response +import requests.auth +import requests.exceptions from tamr_unify_client.dataset.collection import DatasetCollection from tamr_unify_client.project.collection import ProjectCollection -# monkey-patch Response.successful +logger = logging.getLogger(__name__) -def successful(self): - """Checks that this response did not encounter an HTTP error (i.e. status code indicates success: 2xx, 3xx). +def successful(response: requests.Response) -> requests.Response: + """Checks that this response did not encounter an HTTP error. - :raises :class:`requests.exceptions.HTTPError`: If an HTTP error is encountered. - :return: The calling response (i.e. ``self``). - :rtype: :class:`requests.Response` + HTTP error codes match 4xx or 5xx. + + Returns: + The response being checked. + + Raises: + requests.exceptions.HTTPError: If an HTTP error is encountered. """ - self.raise_for_status() - return self + try: + response.raise_for_status() + except requests.exceptions.HTTPError as e: + logger.error(f"HTTP error code response body: {e.response.text}") + raise e + return response + +# monkey-patch requests.Response.successful +requests.Response.successful = successful -Response.successful = successful + +def _default_response_message(response: requests.Response) -> str: + req = response.request + return f"{req.method} {response.url} : {response.status_code}" class Client: - """Python Client for Tamr API. Each client is specific to a specific origin - (protocol, host, port). - - :param auth: Tamr-compatible Authentication provider. - **Recommended**: use one of the classes described in :ref:`authentication` - :type auth: :class:`requests.auth.AuthBase` - :param host: Host address of remote Tamr instance (e.g. `10.0.10.0`). Default: `'localhost'` - :type host: str - :param protocol: Either `'http'` or `'https'`. Default: `'http'` - :type protocol: str - :param port: Tamr instance main port. Default: `9100` - :type port: int - :param base_path: Base API path. Requests made by this client will be relative to this path. Default: `'api/versioned/v1/'` - :type base_path: str - :param session: Session to use for API calls. Default: A new default `requests.Session()`. - :type session: requests.Session - - Usage: - >>> import tamr_unify_client as api + """Python Client for Tamr API. + + Each client is specific to a specific origin (protocol, host, port). + + Args: + auth: Tamr-compatible Authentication provider. + + **Recommended**: use one of the classes described in :ref:`authentication` + host: Host address of remote Tamr instance (e.g. ``'10.0.10.0'``) + protocol: Either ``'http'`` or ``'https'`` + port: Tamr instance main port + base_path: Base API path. Requests made by this client will be relative to this path. + session: Session to use for API calls. If none is provided, will use a new :class:`requests.Session`. + + Example: + >>> from tamr_unify_client import Client >>> from tamr_unify_client.auth import UsernamePasswordAuth >>> auth = UsernamePasswordAuth('my username', 'my password') - >>> local = api.Client(auth) # on http://localhost:9100 - >>> remote = api.Client(auth, protocol='https', host='10.0.10.0') # on https://10.0.10.0:9100 + >>> tamr_local = Client(auth) # on http://localhost:9100 + >>> tamr_remote = Client(auth, protocol='https', host='10.0.10.0') # on https://10.0.10.0:9100 """ def __init__( self, - auth, - host="localhost", - protocol="http", - port=9100, - base_path="/api/versioned/v1/", - session=None, + auth: requests.auth.AuthBase, + host: str = "localhost", + protocol: str = "http", + port: int = 9100, + base_path: str = "/api/versioned/v1/", + session: Optional[requests.Session] = None, ): self.auth = auth self.host = host @@ -68,20 +82,13 @@ def __init__( self._projects = ProjectCollection(self) self._datasets = DatasetCollection(self) - # logging - self.logger = None - # https://docs.python.org/3/howto/logging-cookbook.html#implementing-structured-logging - if not self.base_path.startswith("/"): self.base_path = "/" + self.base_path if not self.base_path.endswith("/"): self.base_path = self.base_path + "/" - def default_log_entry(method, url, response): - return f"{method} {url} : {response.status_code}" - - self.log_entry = None + self.response_message = _default_response_message @property def origin(self): @@ -105,12 +112,7 @@ def request(self, method, endpoint, **kwargs): """ url = urljoin(self.origin + self.base_path, endpoint) response = self.session.request(method, url, auth=self.auth, **kwargs) - - # logging - if self.logger: - log_message = self.log_entry(method, url, response) - self.logger.info(log_message) - + logger.info(self.response_message(response)) return response def get(self, endpoint, **kwargs): From 7127066e98790ed95e2f37299d9ddc5600d87a1e Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 19 Dec 2019 13:20:46 -0500 Subject: [PATCH 201/632] Rewrite logging docs Moved out of advanced usage since many users will want to do this and it is no longer that hard to set up. --- docs/index.md | 1 + docs/user-guide/advanced-usage.md | 47 ---------------------------- docs/user-guide/logging.md | 52 +++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 47 deletions(-) create mode 100644 docs/user-guide/logging.md diff --git a/docs/index.md b/docs/index.md index 9ad61c6f..596154a4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -33,6 +33,7 @@ assert op.succeeded() * [Secure credentials](user-guide/secure-credentials) * [Workflows](user-guide/workflows) * [Create and update resources](user-guide/spec) + * [Logging](user-guide/logging) * [Geospatial data](user-guide/geo) * [Advanced usage](user-guide/advanced-usage) diff --git a/docs/user-guide/advanced-usage.md b/docs/user-guide/advanced-usage.md index 07c999fa..3dee4e98 100644 --- a/docs/user-guide/advanced-usage.md +++ b/docs/user-guide/advanced-usage.md @@ -13,53 +13,6 @@ op = op.wait() # hangs until operation finishes assert op.succeeded() ``` -## Logging API calls - -It can be useful (e.g. for debugging) to log the API calls made on your behalf by the Python Client. - -You can set up HTTP-API-call logging on any client via -standard [Python logging mechanisms](https://docs.python.org/3/library/logging.html): - -```python -from tamr_unify_client import Client -from tamr_unify_client import UsernamePasswordAuth -import logging - -auth = UsernamePasswordAuth("username", "password") -tamr = Client(auth) - -# Reload the `logging` library since other libraries (like `requests`) already -# configure logging differently. See: https://stackoverflow.com/a/53553516/1490091 -import imp -imp.reload(logging) - -logging.basicConfig( - level=logging.INFO, format="%(message)s", filename=log_path, filemode="w" -) -tamr.logger = logging.getLogger(name) -``` - -By default, when logging is set up, the client will log `{method} {url} : {response_status}` for each API call. - -You can customize this by passing in a value for `log_entry`: - -```python -def log_entry(method, url, response): -# custom logging function -# use the method, url, and response to construct the logged `str` -# e.g. for logging out machine-readable JSON: -import json -return json.dumps({ - "request": f"{method} {url}", - "status": response.status_code, - "json": response.json(), -}) - -# after configuring `tamr.logger` -tamr.log_entry = log_entry -``` - - ## Raw HTTP requests and Unversioned API Access We encourage you to use the high-level, object-oriented interface offered by the Python Client. If you aren't sure whether you need to send low-level HTTP requests, you probably don't. diff --git a/docs/user-guide/logging.md b/docs/user-guide/logging.md new file mode 100644 index 00000000..506de943 --- /dev/null +++ b/docs/user-guide/logging.md @@ -0,0 +1,52 @@ +# Logging + +**IMPORTANT** Make sure to configure logging BEFORE `import`ing from 3rd party +libraries. Logging will use the first configuration it finds, and if a library +configures logging before you, your configuration will be ignored. + +--- + +To configure logging, simply follow the [official Python logging HOWTO](https://docs.python.org/3/howto/logging.html#logging-howto). + +For example: +```python +# script.py +import logging + +logging.basicConfig(filename="script.log", level=logging.INFO) + +# configure logging before other imports + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth + +auth = UsernamePasswordAuth("my username", "my password") +tamr = Client(auth, host="myhost") + +for p in tamr.projects: + print(p) + +for d in tamr.datasets: + print(d) + +# should cause an HTTP error +tamr.get("/invalid/api/path").successful() +``` + +This will log all API requests made and print the response bodies for any +requests with HTTP error codes. + +If you want to **only** configure logging for the Tamr Client: +```python +import logging +logger = logging.getLogger('tamr_unify_client') +logger.setLevel(logging.INFO) +logger.addHandler(logging.FileHandler('tamr-client.log')) + +# configure logging before other imports + +from tamr_unify_client import Client +from tamr_unify_client import UsernamePasswordAuth + +# rest of script goes here +``` From b05e022d3a5569d1371cd41c4004098585f2d84f Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 19 Dec 2019 16:13:01 -0500 Subject: [PATCH 202/632] Change wording from "check" to "ensure" "Check" could connote a boolean. "Ensure" connotes an assertion that could result in an exception being raised. --- tamr_unify_client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_unify_client/client.py b/tamr_unify_client/client.py index a71b116a..2b830bd8 100644 --- a/tamr_unify_client/client.py +++ b/tamr_unify_client/client.py @@ -13,7 +13,7 @@ def successful(response: requests.Response) -> requests.Response: - """Checks that this response did not encounter an HTTP error. + """Ensure response does not contain an HTTP error. HTTP error codes match 4xx or 5xx. From 6eb37805ba596b8e9bf404a0be36fb6334ee0ec2 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 19 Dec 2019 16:13:50 -0500 Subject: [PATCH 203/632] Transparently say that we are delegating to requests.Response.raise_for_status --- tamr_unify_client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_unify_client/client.py b/tamr_unify_client/client.py index 2b830bd8..9bfe4aef 100644 --- a/tamr_unify_client/client.py +++ b/tamr_unify_client/client.py @@ -15,7 +15,7 @@ def successful(response: requests.Response) -> requests.Response: """Ensure response does not contain an HTTP error. - HTTP error codes match 4xx or 5xx. + Delegates to :func:`requests.Response.raise_for_status` Returns: The response being checked. From 50699db9ecc6800508e0392800ba0640aad8de55 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 19 Dec 2019 16:15:00 -0500 Subject: [PATCH 204/632] Phrasing for clarity (1) HTTP error code *necessitates* logging of (2) response body. --- tamr_unify_client/client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tamr_unify_client/client.py b/tamr_unify_client/client.py index 9bfe4aef..b591a5f7 100644 --- a/tamr_unify_client/client.py +++ b/tamr_unify_client/client.py @@ -26,7 +26,10 @@ def successful(response: requests.Response) -> requests.Response: try: response.raise_for_status() except requests.exceptions.HTTPError as e: - logger.error(f"HTTP error code response body: {e.response.text}") + r = e.response + logger.error( + f"Encountered HTTP error code {r.status_code}. Response body: {r.text}" + ) raise e return response From 80fa334bbfd584f257ae08263cc644178712fd9e Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 19 Dec 2019 16:16:24 -0500 Subject: [PATCH 205/632] Inline response logging Response logging used to be configurable to allow users to log the response body. Now, we have more robust logging so users don't need to customize response logging. --- tamr_unify_client/client.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tamr_unify_client/client.py b/tamr_unify_client/client.py index b591a5f7..29c9fcde 100644 --- a/tamr_unify_client/client.py +++ b/tamr_unify_client/client.py @@ -38,11 +38,6 @@ def successful(response: requests.Response) -> requests.Response: requests.Response.successful = successful -def _default_response_message(response: requests.Response) -> str: - req = response.request - return f"{req.method} {response.url} : {response.status_code}" - - class Client: """Python Client for Tamr API. @@ -91,8 +86,6 @@ def __init__( if not self.base_path.endswith("/"): self.base_path = self.base_path + "/" - self.response_message = _default_response_message - @property def origin(self): """HTTP origin i.e. ``://[:]``. @@ -115,7 +108,10 @@ def request(self, method, endpoint, **kwargs): """ url = urljoin(self.origin + self.base_path, endpoint) response = self.session.request(method, url, auth=self.auth, **kwargs) - logger.info(self.response_message(response)) + + logger.info( + f"{response.request.method} {response.url} : {response.status_code}" + ) return response def get(self, endpoint, **kwargs): From 1586fb65eb4750660cf6f56935ecef26c931b9e7 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 19 Dec 2019 16:17:51 -0500 Subject: [PATCH 206/632] More type annotations and Google-style docstrings --- tamr_unify_client/client.py | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/tamr_unify_client/client.py b/tamr_unify_client/client.py index 29c9fcde..bb3fa62f 100644 --- a/tamr_unify_client/client.py +++ b/tamr_unify_client/client.py @@ -87,24 +87,25 @@ def __init__( self.base_path = self.base_path + "/" @property - def origin(self): + def origin(self) -> str: """HTTP origin i.e. ``://[:]``. - For additional information, see `MDN web docs `_ . - :type: str + For additional information, see `MDN web docs `_ . """ return f"{self.protocol}://{self.host}:{self.port}" - def request(self, method, endpoint, **kwargs): - """Sends an authenticated request to the server. The URL for the request - will be ``"//"``. + def request(self, method: str, endpoint: str, **kwargs) -> requests.Response: + """Sends a request to Tamr. + + The URL for the request will be ``//``. + The request is authenticated via :attr:`Client.auth`. + + Args: + method: The HTTP method to use (e.g. `'GET'` or `'POST'`) + endpoint: API endpoint to call (relative to the Base API path for this client). - :param method: The HTTP method for the request to be sent. - :type method: str - :param endpoint: API endpoint to call (relative to the Base API path for this client). - :type endpoint: str - :return: HTTP response - :rtype: :class:`requests.Response` + Returns: + HTTP response from the Tamr server """ url = urljoin(self.origin + self.base_path, endpoint) response = self.session.request(method, url, auth=self.auth, **kwargs) @@ -135,20 +136,20 @@ def delete(self, endpoint, **kwargs): return self.request("DELETE", endpoint, **kwargs) @property - def projects(self): + def projects(self) -> ProjectCollection: """Collection of all projects on this Tamr instance. - :return: Collection of all projects. - :rtype: :class:`~tamr_unify_client.project.collection.ProjectCollection` + Returns: + Collection of all projects. """ return self._projects @property - def datasets(self): + def datasets(self) -> DatasetCollection: """Collection of all datasets on this Tamr instance. - :return: Collection of all datasets. - :rtype: :class:`~tamr_unify_client.dataset.collection.DatasetCollection` + Returns: + Collection of all datasets. """ return self._datasets From 084053433adb822929c2101b834245cc5f475e3d Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 19 Dec 2019 16:27:07 -0500 Subject: [PATCH 207/632] poetry: 'allows-preleases' is deprecated. use 'allow-prereleases' instead. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a62f3880..2e5e24cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ Sphinx = "^2.1" responses = "^0.10.6" flake8-import-order = "^0.18.1" pytest = "^4.6" -black = {version = "^19.3b0",allows-prereleases = true} +black = {version = "^19.3b0",allow-prereleases = true} flake8 = "^3.7" toml = "^0.10.0" sphinx_rtd_theme = "^0.4.3" From c7d2096920b90e833ae4b334213c4498116c52f1 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 20 Dec 2019 16:42:21 -0500 Subject: [PATCH 208/632] Changelog entry for logging improvements --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42d633bb..77e6d4c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## 0.10.0-dev **BREAKING CHANGES** - [#309](https://github.com/Datatamer/tamr-client/issues/309) Migrate `SubAttribute` to use `@dataclass(frozen=True)`. `SubAttribute.__init__` constructor replaced with the one generated by `@dataclass`. `SubAttribute`s should be constructed via the `SubAttribute.from_json` static method. + - [#307](https://github.com/Datatamer/tamr-client/issues/307) Logging improvements: (1) Use standard logging best practices (2) log response body for responses containing HTTP error codes. **BUG FIXES** - [#293](https://github.com/Datatamer/tamr-client/issues/293) Better handling for HTTP 204 on already up-to-date operations From d221cdde315b40aac43391958dd70a93aff04b38 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 20 Dec 2019 18:06:40 -0500 Subject: [PATCH 209/632] Describe migration for breaking change in logging --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77e6d4c7..80ed6dde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## 0.10.0-dev **BREAKING CHANGES** - [#309](https://github.com/Datatamer/tamr-client/issues/309) Migrate `SubAttribute` to use `@dataclass(frozen=True)`. `SubAttribute.__init__` constructor replaced with the one generated by `@dataclass`. `SubAttribute`s should be constructed via the `SubAttribute.from_json` static method. - - [#307](https://github.com/Datatamer/tamr-client/issues/307) Logging improvements: (1) Use standard logging best practices (2) log response body for responses containing HTTP error codes. + - [#307](https://github.com/Datatamer/tamr-client/issues/307) Logging improvements: (1) Use standard logging best practices (2) log response body for responses containing HTTP error codes. Previous way to configure logging (via `Client.logger` and `Client.log_entry`) have been replaced. See [User Guide > Logging](https://tamr-client.readthedocs.io/en/latest/user-guide/logging.html). **BUG FIXES** - [#293](https://github.com/Datatamer/tamr-client/issues/293) Better handling for HTTP 204 on already up-to-date operations From d84dfb490968008c8a3fc67382a3ba9e7294a3c5 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Sat, 21 Dec 2019 18:19:36 -0500 Subject: [PATCH 210/632] Upgrade poetry to 1.0 --- poetry.lock | 419 ++++++++++++++++++++++++++++++++++++++++++------- pyproject.toml | 2 +- 2 files changed, 363 insertions(+), 58 deletions(-) diff --git a/poetry.lock b/poetry.lock index 23b7c9b7..3f1bbeb1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -30,6 +30,11 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "19.1.0" +[package.extras] +dev = ["coverage", "hypothesis", "pympler", "pytest", "six", "zope.interface", "sphinx", "pre-commit"] +docs = ["sphinx", "zope.interface"] +tests = ["coverage", "hypothesis", "pympler", "pytest", "six", "zope.interface"] + [[package]] category = "dev" description = "Internationalization utilities" @@ -55,6 +60,9 @@ attrs = ">=18.1.0" click = ">=6.5" toml = ">=0.9.4" +[package.extras] +d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] + [[package]] category = "main" description = "Python package for providing Mozilla's CA Bundle." @@ -99,6 +107,9 @@ version = "0.9.0" [package.dependencies] future = "*" +[package.extras] +test = ["flake8 (3.5.0)", "hypothesis (3.55.3)"] + [[package]] category = "main" description = "A backport of the dataclasses module for Python 3.6" @@ -184,6 +195,9 @@ version = "0.17" [package.dependencies] zipp = ">=0.5" +[package.extras] +docs = ["sphinx", "docutils (0.12)", "rst.linker"] + [[package]] category = "dev" description = "A small but fast and easy to use stand-alone template engine written in pure python." @@ -195,6 +209,9 @@ version = "2.10.1" [package.dependencies] MarkupSafe = ">=0.23" +[package.extras] +i18n = ["Babel (>=0.8)"] + [[package]] category = "dev" description = "Safely add untrusted strings to HTML/XML markup." @@ -253,6 +270,9 @@ numpy = ">=1.13.3" python-dateutil = ">=2.6.1" pytz = ">=2017.2" +[package.extras] +test = ["pytest (>=4.0.2)", "pytest-xdist", "hypothesis (>=3.58)"] + [[package]] category = "dev" description = "plugin and hook calling mechanisms for python" @@ -264,6 +284,9 @@ version = "0.12.0" [package.dependencies] importlib-metadata = ">=0.12" +[package.extras] +dev = ["pre-commit", "tox"] + [[package]] category = "dev" description = "library with cross-python path, ini-parsing, io, code, log facilities" @@ -327,6 +350,9 @@ wcwidth = "*" python = ">=2.8" version = ">=4.0.0" +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "nose", "requests", "mock"] + [[package]] category = "dev" description = "Extensions to the standard Python datetime module" @@ -373,6 +399,10 @@ chardet = ">=3.0.2,<3.1.0" idna = ">=2.5,<2.9" urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" +[package.extras] +security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)"] +socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] + [[package]] category = "dev" description = "A utility library for mocking out the `requests` Python library." @@ -385,6 +415,9 @@ version = "0.10.6" requests = ">=2.0" six = "*" +[package.extras] +tests = ["pytest", "coverage (>=3.7.1,<5.0.0)", "pytest-cov", "pytest-localserver", "flake8"] + [[package]] category = "main" description = "Simple, fast, extensible JSON encoder/decoder for Python" @@ -436,6 +469,10 @@ sphinxcontrib-jsmath = "*" sphinxcontrib-qthelp = "*" sphinxcontrib-serializinghtml = "*" +[package.extras] +docs = ["sphinxcontrib-websupport"] +test = ["pytest", "pytest-cov", "html5lib", "flake8 (>=3.5.0)", "flake8-import-order", "mypy (>=0.590)", "docutils-stubs"] + [[package]] category = "dev" description = "Type hints (PEP 484) support for the Sphinx autodoc extension" @@ -447,6 +484,10 @@ version = "1.8.0" [package.dependencies] Sphinx = ">=2.1" +[package.extras] +test = ["pytest (>=3.1.0)", "typing-extensions (>=3.5)", "dataclasses"] +type_comments = ["typed-ast (>=1.4.0)"] + [[package]] category = "dev" description = "Read the Docs theme for Sphinx" @@ -466,6 +507,9 @@ optional = false python-versions = "*" version = "1.0.1" +[package.extras] +test = ["pytest", "flake8", "mypy"] + [[package]] category = "dev" description = "" @@ -474,6 +518,9 @@ optional = false python-versions = "*" version = "1.0.1" +[package.extras] +test = ["pytest", "flake8", "mypy"] + [[package]] category = "dev" description = "" @@ -482,6 +529,9 @@ optional = false python-versions = "*" version = "1.0.2" +[package.extras] +test = ["pytest", "flake8", "mypy", "html5lib"] + [[package]] category = "dev" description = "A sphinx extension which renders display math in HTML via JavaScript" @@ -490,6 +540,9 @@ optional = false python-versions = ">=3.5" version = "1.0.1" +[package.extras] +test = ["pytest", "flake8", "mypy"] + [[package]] category = "dev" description = "" @@ -498,6 +551,9 @@ optional = false python-versions = "*" version = "1.0.2" +[package.extras] +test = ["pytest", "flake8", "mypy"] + [[package]] category = "dev" description = "" @@ -506,6 +562,9 @@ optional = false python-versions = "*" version = "1.1.3" +[package.extras] +test = ["pytest", "flake8", "mypy"] + [[package]] category = "dev" description = "Python Library for Tom's Obvious, Minimal Language" @@ -522,6 +581,11 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4" version = "1.25.3" +[package.extras] +brotli = ["brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] + [[package]] category = "dev" description = "Measures number of Terminal column cells of wide-character codes" @@ -538,63 +602,304 @@ optional = false python-versions = ">=2.7" version = "0.5.1" +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] +testing = ["pathlib2", "contextlib2", "unittest2"] + [metadata] -content-hash = "c6299c62f796446ea730148a7dbfe97254f32365337ba10eac0be0326f9f684e" +content-hash = "4f1e35032544d1e693bd92e4e5d92f4dd1048eff062b551effb3d8c1c60d3955" python-versions = "^3.6" -[metadata.hashes] -alabaster = ["446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359", "a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"] -appdirs = ["9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", "d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"] -atomicwrites = ["03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", "75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"] -attrs = ["69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", "f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399"] -babel = ["af92e6106cb7c55286b25b38ad7695f8b4efb36a90ba483d7f7a6628c46158ab", "e86135ae101e31e2c8ec20a4e0c5220f4eed12487d5cf3f78be7e98d3a57fc28"] -black = ["09a9dcb7c46ed496a9850b76e4e825d6049ecd38b611f1224857a79bd985a8cf", "68950ffd4d9169716bcb8719a56c07a2f4485354fec061cdd5910aa07369731c"] -certifi = ["59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5", "b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae"] -chardet = ["84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", "fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"] -click = ["2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", "5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"] -colorama = ["05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", "f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"] -commonmark = ["14c3df31e8c9c463377e287b2a1eefaa6019ab97b22dad36e2f32be59d61d68d", "867fc5db078ede373ab811e16b6789e9d033b15ccd7296f370ca52d1ee792ce0"] -dataclasses = ["454a69d788c7fda44efd71e259be79577822f5e3f53f029a22d08004e951dc9f", "6988bd2b895eef432d562370bb707d540f32f7360ab13da45340101bc2307d84"] -docutils = ["02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6", "51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274", "7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6"] -entrypoints = ["589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", "c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"] -flake8 = ["859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661", "a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8"] -flake8-import-order = ["90a80e46886259b9c396b578d75c749801a41ee969a235e163cfe1be7afd2543", "a28dc39545ea4606c1ac3c24e9d05c849c6e5444a50fb7e9cdd430fc94de6e92"] -future = ["67045236dcfd6816dc439556d009594abf643e5eb48992e36beac09c2ca659b8"] -idna = ["c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", "ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"] -imagesize = ["3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8", "f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5"] -importlib-metadata = ["a9f185022cfa69e9ca5f7eabfd5a58b689894cb78a11e3c8c89398a8ccbb8e7f", "df1403cd3aebeb2b1dcd3515ca062eecb5bd3ea7611f18cba81130c68707e879"] -jinja2 = ["065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013", "14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b"] -markupsafe = ["00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", "09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", "09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", "1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", "24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", "29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", "43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", "46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", "500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", "535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", "62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", "6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", "717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", "79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", "7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", "88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", "8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", "98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", "9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", "9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", "ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", "b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", "b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", "b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", "ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", "c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", "cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", "e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"] -mccabe = ["ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"] -more-itertools = ["2112d2ca570bb7c3e53ea1a35cd5df42bb0fd10c45f0fb97178679c3c03d64c7", "c3e4748ba1aad8dba30a4886b0b1a2004f9a863837b8654e7059eebf727afa5a"] -numpy = ["03e311b0a4c9f5755da7d52161280c6a78406c7be5c5cc7facfbcebb641efb7e", "0cdd229a53d2720d21175012ab0599665f8c9588b3b8ffa6095dd7b90f0691dd", "312bb18e95218bedc3563f26fcc9c1c6bfaaf9d453d15942c0839acdd7e4c473", "464b1c48baf49e8505b1bb754c47a013d2c305c5b14269b5c85ea0625b6a988a", "5adfde7bd3ee4864536e230bcab1c673f866736698724d5d28c11a4d63672658", "7724e9e31ee72389d522b88c0d4201f24edc34277999701ccd4a5392e7d8af61", "8d36f7c53ae741e23f54793ffefb2912340b800476eb0a831c6eb602e204c5c4", "910d2272403c2ea8a52d9159827dc9f7c27fb4b263749dca884e2e4a8af3b302", "951fefe2fb73f84c620bec4e001e80a80ddaa1b84dce244ded7f1e0cbe0ed34a", "9588c6b4157f493edeb9378788dcd02cb9e6a6aeaa518b511a1c79d06cbd8094", "9ce8300950f2f1d29d0e49c28ebfff0d2f1e2a7444830fbb0b913c7c08f31511", "be39cca66cc6806652da97103605c7b65ee4442c638f04ff064a7efd9a81d50a", "c3ab2d835b95ccb59d11dfcd56eb0480daea57cdf95d686d22eff35584bc4554", "eb0fc4a492cb896346c9e2c7a22eae3e766d407df3eb20f4ce027f23f76e4c54", "ec0c56eae6cee6299f41e780a0280318a93db519bbb2906103c43f3e2be1206c", "f4e4612de60a4f1c4d06c8c2857cdcb2b8b5289189a12053f37d3f41f06c60d0"] -packaging = ["0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af", "9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3"] -pandas = ["074a032f99bb55d178b93bd98999c971542f19317829af08c99504febd9e9b8b", "20f1728182b49575c2f6f681b3e2af5fac9e84abdf29488e76d569a7969b362e", "2745ba6e16c34d13d765c3657bb64fa20a0e2daf503e6216a36ed61770066179", "32c44e5b628c48ba17703f734d59f369d4cdcb4239ef26047d6c8a8bfda29a6b", "3b9f7dcee6744d9dcdd53bce19b91d20b4311bf904303fa00ef58e7df398e901", "544f2033250980fb6f069ce4a960e5f64d99b8165d01dc39afd0b244eeeef7d7", "58f9ef68975b9f00ba96755d5702afdf039dea9acef6a0cfd8ddcde32918a79c", "9023972a92073a495eba1380824b197ad1737550fe1c4ef8322e65fe58662888", "914341ad2d5b1ea522798efa4016430b66107d05781dbfe7cf05eba8f37df995", "9d151bfb0e751e2c987f931c57792871c8d7ff292bcdfcaa7233012c367940ee", "b932b127da810fef57d427260dde1ad54542c136c44b227a1e367551bb1a684b", "cfb862aa37f4dd5be0730731fdb8185ac935aba8b51bf3bd035658111c9ee1c9", "de7ecb4b120e98b91e8a2a21f186571266a8d1faa31d92421e979c7ca67d8e5c", "df7e1933a0b83920769611c5d6b9a1bf301e3fa6a544641c6678c67621fe9843"] -pluggy = ["0825a152ac059776623854c1543d65a4ad408eb3d33ee114dff91e57ec6ae6fc", "b9817417e95936bf75d85d3f8767f7df6cdde751fc40aed3bb3074cbcb77757c"] -py = ["64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", "dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"] -pycodestyle = ["95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", "e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"] -pyflakes = ["17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", "d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"] -pygments = ["71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", "881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297"] -pyparsing = ["1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a", "9b6323ef4ab914af344ba97510e966d64ba91055d6b9afa6b30799340e89cc03"] -pytest = ["6032845e68a17a96e8da3088037f899b56357769a724122056265ca2ea1890ee", "bea27a646a3d74cbbcf8d3d4a06b2dfc336baf3dc2cc85cf70ad0157e73e8322"] -python-dateutil = ["7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb", "c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e"] -pytz = ["303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda", "d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141"] -recommonmark = ["29cd4faeb6c5268c633634f2d69aef9431e0f4d347f90659fd0aab20e541efeb", "2ec4207a574289355d5b6ae4ae4abb29043346ca12cdd5f07d374dc5987d2852"] -requests = ["11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", "9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"] -responses = ["502d9c0c8008439cfcdef7e251f507fcfdd503b56e8c0c87c3c3e3393953f790", "97193c0183d63fba8cd3a041c75464e4b09ea0aff6328800d1546598567dde0b"] -simplejson = ["067a7177ddfa32e1483ba5169ebea1bc2ea27f224853211ca669325648ca5642", "2b8cb601d9ba0381499db719ccc9dfbb2fbd16013f5ff096b1a68a4775576a04", "2c139daf167b96f21542248f8e0a06596c9b9a7a41c162cc5c9ee9f3833c93cd", "2fc546e6af49fb45b93bbe878dea4c48edc34083729c0abd09981fe55bdf7f91", "354fa32b02885e6dae925f1b5bbf842c333c1e11ea5453ddd67309dc31fdb40a", "37e685986cf6f8144607f90340cff72d36acf654f3653a6c47b84c5c38d00df7", "3af610ee72efbe644e19d5eaad575c73fb83026192114e5f6719f4901097fce2", "3b919fc9cf508f13b929a9b274c40786036b31ad28657819b3b9ba44ba651f50", "3dd289368bbd064974d9a5961101f080e939cbe051e6689a193c99fb6e9ac89b", "491de7acc423e871a814500eb2dcea8aa66c4a4b1b4825d18f756cdf58e370cb", "495511fe5f10ccf4e3ed4fc0c48318f533654db6c47ecbc970b4ed215c791968", "65b41a5cda006cfa7c66eabbcf96aa704a6be2a5856095b9e2fd8c293bad2b46", "6c3258ffff58712818a233b9737fe4be943d306c40cf63d14ddc82ba563f483a", "75e3f0b12c28945c08f54350d91e624f8dd580ab74fd4f1bbea54bc6b0165610", "79b129fe65fdf3765440f7a73edaffc89ae9e7885d4e2adafe6aa37913a00fbb", "b1f329139ba647a9548aa05fb95d046b4a677643070dc2afc05fa2e975d09ca5", "c206f47cbf9f32b573c9885f0ec813d2622976cf5effcf7e472344bc2e020ac1", "d8e238f20bcf70063ee8691d4a72162bcec1f4c38f83c93e6851e72ad545dabb", "ee9625fc8ee164902dfbb0ff932b26df112da9f871c32f0f9c1bcf20c350fe2a", "fb2530b53c28f0d4d84990e945c2ebb470edb469d63e389bf02ff409012fe7c5", "feadb95170e45f439455354904768608e356c5b174ca30b3d11b0e3f24b5c0df"] -six = ["3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", "d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"] -snowballstemmer = ["919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128", "9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89"] -sphinx = ["2c5becc0fd6706dc0aeb4703f9f1f8a1d1eecacf02e9ac5943cbae48b11e5e42", "7a359a91fb04054ec77d68ff97cb8728f8cc322e25f22dc94299d67e0e6a7123"] -sphinx-autodoc-typehints = ["0d968ec3ee4f7fe7695ab6facf5cd2d74d3cea67584277458ad9b2788ebbcc3b", "8edca714fd3de8e43467d7e51dd3812fe999f8874408a639f7c38a9e1a5a4eb3"] -sphinx-rtd-theme = ["00cf895504a7895ee433807c62094cf1e95f065843bf3acd17037c3e9a2becd4", "728607e34d60456d736cc7991fd236afb828b21b82f956c5ea75f94c8414040a"] -sphinxcontrib-applehelp = ["edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897", "fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d"] -sphinxcontrib-devhelp = ["6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34", "9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981"] -sphinxcontrib-htmlhelp = ["4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422", "d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7"] -sphinxcontrib-jsmath = ["2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", "a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"] -sphinxcontrib-qthelp = ["513049b93031beb1f57d4daea74068a4feb77aa5630f856fcff2e50de14e9a20", "79465ce11ae5694ff165becda529a600c754f4bc459778778c7017374d4d406f"] -sphinxcontrib-serializinghtml = ["c0efb33f8052c04fd7a26c0a07f1678e8512e0faec19f4aa8f2473a8b81d5227", "db6615af393650bf1151a6cd39120c29abaf93cc60db8c48eb2dddbfdc3a9768"] -toml = ["229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", "235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e", "f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"] -urllib3 = ["b246607a25ac80bedac05c6f282e3cdaf3afb65420fd024ac94435cabe6e18d1", "dbe59173209418ae49d485b87d1681aefa36252ee85884c31346debd19463232"] -wcwidth = ["3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", "f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c"] -zipp = ["8c1019c6aad13642199fbe458275ad6a84907634cc9f0989877ccc4a2840139d", "ca943a7e809cc12257001ccfb99e3563da9af99d52f261725e96dfe0f9275bc3"] +[metadata.files] +alabaster = [ + {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, + {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, +] +appdirs = [ + {file = "appdirs-1.4.3-py2.py3-none-any.whl", hash = "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"}, + {file = "appdirs-1.4.3.tar.gz", hash = "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92"}, +] +atomicwrites = [ + {file = "atomicwrites-1.3.0-py2.py3-none-any.whl", hash = "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4"}, + {file = "atomicwrites-1.3.0.tar.gz", hash = "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"}, +] +attrs = [ + {file = "attrs-19.1.0-py2.py3-none-any.whl", hash = "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79"}, + {file = "attrs-19.1.0.tar.gz", hash = "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399"}, +] +babel = [ + {file = "Babel-2.7.0-py2.py3-none-any.whl", hash = "sha256:af92e6106cb7c55286b25b38ad7695f8b4efb36a90ba483d7f7a6628c46158ab"}, + {file = "Babel-2.7.0.tar.gz", hash = "sha256:e86135ae101e31e2c8ec20a4e0c5220f4eed12487d5cf3f78be7e98d3a57fc28"}, +] +black = [ + {file = "black-19.3b0-py36-none-any.whl", hash = "sha256:09a9dcb7c46ed496a9850b76e4e825d6049ecd38b611f1224857a79bd985a8cf"}, + {file = "black-19.3b0.tar.gz", hash = "sha256:68950ffd4d9169716bcb8719a56c07a2f4485354fec061cdd5910aa07369731c"}, +] +certifi = [ + {file = "certifi-2019.3.9-py2.py3-none-any.whl", hash = "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5"}, + {file = "certifi-2019.3.9.tar.gz", hash = "sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae"}, +] +chardet = [ + {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, + {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, +] +click = [ + {file = "Click-7.0-py2.py3-none-any.whl", hash = "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13"}, + {file = "Click-7.0.tar.gz", hash = "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"}, +] +colorama = [ + {file = "colorama-0.4.1-py2.py3-none-any.whl", hash = "sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"}, + {file = "colorama-0.4.1.tar.gz", hash = "sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d"}, +] +commonmark = [ + {file = "commonmark-0.9.0-py2.py3-none-any.whl", hash = "sha256:14c3df31e8c9c463377e287b2a1eefaa6019ab97b22dad36e2f32be59d61d68d"}, + {file = "commonmark-0.9.0.tar.gz", hash = "sha256:867fc5db078ede373ab811e16b6789e9d033b15ccd7296f370ca52d1ee792ce0"}, +] +dataclasses = [ + {file = "dataclasses-0.6-py3-none-any.whl", hash = "sha256:454a69d788c7fda44efd71e259be79577822f5e3f53f029a22d08004e951dc9f"}, + {file = "dataclasses-0.6.tar.gz", hash = "sha256:6988bd2b895eef432d562370bb707d540f32f7360ab13da45340101bc2307d84"}, +] +docutils = [ + {file = "docutils-0.14-py2-none-any.whl", hash = "sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6"}, + {file = "docutils-0.14-py3-none-any.whl", hash = "sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6"}, + {file = "docutils-0.14.tar.gz", hash = "sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274"}, +] +entrypoints = [ + {file = "entrypoints-0.3-py2.py3-none-any.whl", hash = "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19"}, + {file = "entrypoints-0.3.tar.gz", hash = "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"}, +] +flake8 = [ + {file = "flake8-3.7.7-py2.py3-none-any.whl", hash = "sha256:a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8"}, + {file = "flake8-3.7.7.tar.gz", hash = "sha256:859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661"}, +] +flake8-import-order = [ + {file = "flake8-import-order-0.18.1.tar.gz", hash = "sha256:a28dc39545ea4606c1ac3c24e9d05c849c6e5444a50fb7e9cdd430fc94de6e92"}, + {file = "flake8_import_order-0.18.1-py2.py3-none-any.whl", hash = "sha256:90a80e46886259b9c396b578d75c749801a41ee969a235e163cfe1be7afd2543"}, +] +future = [ + {file = "future-0.17.1.tar.gz", hash = "sha256:67045236dcfd6816dc439556d009594abf643e5eb48992e36beac09c2ca659b8"}, +] +idna = [ + {file = "idna-2.8-py2.py3-none-any.whl", hash = "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"}, + {file = "idna-2.8.tar.gz", hash = "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407"}, +] +imagesize = [ + {file = "imagesize-1.1.0-py2.py3-none-any.whl", hash = "sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8"}, + {file = "imagesize-1.1.0.tar.gz", hash = "sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5"}, +] +importlib-metadata = [ + {file = "importlib_metadata-0.17-py2.py3-none-any.whl", hash = "sha256:df1403cd3aebeb2b1dcd3515ca062eecb5bd3ea7611f18cba81130c68707e879"}, + {file = "importlib_metadata-0.17.tar.gz", hash = "sha256:a9f185022cfa69e9ca5f7eabfd5a58b689894cb78a11e3c8c89398a8ccbb8e7f"}, +] +jinja2 = [ + {file = "Jinja2-2.10.1-py2.py3-none-any.whl", hash = "sha256:14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b"}, + {file = "Jinja2-2.10.1.tar.gz", hash = "sha256:065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013"}, +] +markupsafe = [ + {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"}, + {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"}, + {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, + {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, +] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] +more-itertools = [ + {file = "more-itertools-7.0.0.tar.gz", hash = "sha256:c3e4748ba1aad8dba30a4886b0b1a2004f9a863837b8654e7059eebf727afa5a"}, + {file = "more_itertools-7.0.0-py3-none-any.whl", hash = "sha256:2112d2ca570bb7c3e53ea1a35cd5df42bb0fd10c45f0fb97178679c3c03d64c7"}, +] +numpy = [ + {file = "numpy-1.17.0-cp35-cp35m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:910d2272403c2ea8a52d9159827dc9f7c27fb4b263749dca884e2e4a8af3b302"}, + {file = "numpy-1.17.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:9ce8300950f2f1d29d0e49c28ebfff0d2f1e2a7444830fbb0b913c7c08f31511"}, + {file = "numpy-1.17.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7724e9e31ee72389d522b88c0d4201f24edc34277999701ccd4a5392e7d8af61"}, + {file = "numpy-1.17.0-cp35-cp35m-win32.whl", hash = "sha256:0cdd229a53d2720d21175012ab0599665f8c9588b3b8ffa6095dd7b90f0691dd"}, + {file = "numpy-1.17.0-cp35-cp35m-win_amd64.whl", hash = "sha256:5adfde7bd3ee4864536e230bcab1c673f866736698724d5d28c11a4d63672658"}, + {file = "numpy-1.17.0-cp36-cp36m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:464b1c48baf49e8505b1bb754c47a013d2c305c5b14269b5c85ea0625b6a988a"}, + {file = "numpy-1.17.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:eb0fc4a492cb896346c9e2c7a22eae3e766d407df3eb20f4ce027f23f76e4c54"}, + {file = "numpy-1.17.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:9588c6b4157f493edeb9378788dcd02cb9e6a6aeaa518b511a1c79d06cbd8094"}, + {file = "numpy-1.17.0-cp36-cp36m-win32.whl", hash = "sha256:03e311b0a4c9f5755da7d52161280c6a78406c7be5c5cc7facfbcebb641efb7e"}, + {file = "numpy-1.17.0-cp36-cp36m-win_amd64.whl", hash = "sha256:c3ab2d835b95ccb59d11dfcd56eb0480daea57cdf95d686d22eff35584bc4554"}, + {file = "numpy-1.17.0-cp37-cp37m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:f4e4612de60a4f1c4d06c8c2857cdcb2b8b5289189a12053f37d3f41f06c60d0"}, + {file = "numpy-1.17.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:312bb18e95218bedc3563f26fcc9c1c6bfaaf9d453d15942c0839acdd7e4c473"}, + {file = "numpy-1.17.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:8d36f7c53ae741e23f54793ffefb2912340b800476eb0a831c6eb602e204c5c4"}, + {file = "numpy-1.17.0-cp37-cp37m-win32.whl", hash = "sha256:ec0c56eae6cee6299f41e780a0280318a93db519bbb2906103c43f3e2be1206c"}, + {file = "numpy-1.17.0-cp37-cp37m-win_amd64.whl", hash = "sha256:be39cca66cc6806652da97103605c7b65ee4442c638f04ff064a7efd9a81d50a"}, + {file = "numpy-1.17.0.zip", hash = "sha256:951fefe2fb73f84c620bec4e001e80a80ddaa1b84dce244ded7f1e0cbe0ed34a"}, +] +packaging = [ + {file = "packaging-19.0-py2.py3-none-any.whl", hash = "sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3"}, + {file = "packaging-19.0.tar.gz", hash = "sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af"}, +] +pandas = [ + {file = "pandas-0.25.0-cp35-cp35m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:32c44e5b628c48ba17703f734d59f369d4cdcb4239ef26047d6c8a8bfda29a6b"}, + {file = "pandas-0.25.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:df7e1933a0b83920769611c5d6b9a1bf301e3fa6a544641c6678c67621fe9843"}, + {file = "pandas-0.25.0-cp35-cp35m-win32.whl", hash = "sha256:cfb862aa37f4dd5be0730731fdb8185ac935aba8b51bf3bd035658111c9ee1c9"}, + {file = "pandas-0.25.0-cp35-cp35m-win_amd64.whl", hash = "sha256:b932b127da810fef57d427260dde1ad54542c136c44b227a1e367551bb1a684b"}, + {file = "pandas-0.25.0-cp36-cp36m-macosx_10_9_x86_64.macosx_10_10_x86_64.whl", hash = "sha256:3b9f7dcee6744d9dcdd53bce19b91d20b4311bf904303fa00ef58e7df398e901"}, + {file = "pandas-0.25.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:074a032f99bb55d178b93bd98999c971542f19317829af08c99504febd9e9b8b"}, + {file = "pandas-0.25.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:9023972a92073a495eba1380824b197ad1737550fe1c4ef8322e65fe58662888"}, + {file = "pandas-0.25.0-cp36-cp36m-win32.whl", hash = "sha256:20f1728182b49575c2f6f681b3e2af5fac9e84abdf29488e76d569a7969b362e"}, + {file = "pandas-0.25.0-cp36-cp36m-win_amd64.whl", hash = "sha256:58f9ef68975b9f00ba96755d5702afdf039dea9acef6a0cfd8ddcde32918a79c"}, + {file = "pandas-0.25.0-cp37-cp37m-macosx_10_9_x86_64.macosx_10_10_x86_64.whl", hash = "sha256:2745ba6e16c34d13d765c3657bb64fa20a0e2daf503e6216a36ed61770066179"}, + {file = "pandas-0.25.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:9d151bfb0e751e2c987f931c57792871c8d7ff292bcdfcaa7233012c367940ee"}, + {file = "pandas-0.25.0-cp37-cp37m-win32.whl", hash = "sha256:de7ecb4b120e98b91e8a2a21f186571266a8d1faa31d92421e979c7ca67d8e5c"}, + {file = "pandas-0.25.0-cp37-cp37m-win_amd64.whl", hash = "sha256:544f2033250980fb6f069ce4a960e5f64d99b8165d01dc39afd0b244eeeef7d7"}, + {file = "pandas-0.25.0.tar.gz", hash = "sha256:914341ad2d5b1ea522798efa4016430b66107d05781dbfe7cf05eba8f37df995"}, +] +pluggy = [ + {file = "pluggy-0.12.0-py2.py3-none-any.whl", hash = "sha256:b9817417e95936bf75d85d3f8767f7df6cdde751fc40aed3bb3074cbcb77757c"}, + {file = "pluggy-0.12.0.tar.gz", hash = "sha256:0825a152ac059776623854c1543d65a4ad408eb3d33ee114dff91e57ec6ae6fc"}, +] +py = [ + {file = "py-1.8.0-py2.py3-none-any.whl", hash = "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa"}, + {file = "py-1.8.0.tar.gz", hash = "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"}, +] +pycodestyle = [ + {file = "pycodestyle-2.5.0-py2.py3-none-any.whl", hash = "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56"}, + {file = "pycodestyle-2.5.0.tar.gz", hash = "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"}, +] +pyflakes = [ + {file = "pyflakes-2.1.1-py2.py3-none-any.whl", hash = "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0"}, + {file = "pyflakes-2.1.1.tar.gz", hash = "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"}, +] +pygments = [ + {file = "Pygments-2.4.2-py2.py3-none-any.whl", hash = "sha256:71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127"}, + {file = "Pygments-2.4.2.tar.gz", hash = "sha256:881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297"}, +] +pyparsing = [ + {file = "pyparsing-2.4.0-py2.py3-none-any.whl", hash = "sha256:9b6323ef4ab914af344ba97510e966d64ba91055d6b9afa6b30799340e89cc03"}, + {file = "pyparsing-2.4.0.tar.gz", hash = "sha256:1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a"}, +] +pytest = [ + {file = "pytest-4.6.2-py2.py3-none-any.whl", hash = "sha256:6032845e68a17a96e8da3088037f899b56357769a724122056265ca2ea1890ee"}, + {file = "pytest-4.6.2.tar.gz", hash = "sha256:bea27a646a3d74cbbcf8d3d4a06b2dfc336baf3dc2cc85cf70ad0157e73e8322"}, +] +python-dateutil = [ + {file = "python-dateutil-2.8.0.tar.gz", hash = "sha256:c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e"}, + {file = "python_dateutil-2.8.0-py2.py3-none-any.whl", hash = "sha256:7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb"}, +] +pytz = [ + {file = "pytz-2019.1-py2.py3-none-any.whl", hash = "sha256:303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda"}, + {file = "pytz-2019.1.tar.gz", hash = "sha256:d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141"}, +] +recommonmark = [ + {file = "recommonmark-0.6.0-py2.py3-none-any.whl", hash = "sha256:2ec4207a574289355d5b6ae4ae4abb29043346ca12cdd5f07d374dc5987d2852"}, + {file = "recommonmark-0.6.0.tar.gz", hash = "sha256:29cd4faeb6c5268c633634f2d69aef9431e0f4d347f90659fd0aab20e541efeb"}, +] +requests = [ + {file = "requests-2.22.0-py2.py3-none-any.whl", hash = "sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"}, + {file = "requests-2.22.0.tar.gz", hash = "sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4"}, +] +responses = [ + {file = "responses-0.10.6-py2.py3-none-any.whl", hash = "sha256:97193c0183d63fba8cd3a041c75464e4b09ea0aff6328800d1546598567dde0b"}, + {file = "responses-0.10.6.tar.gz", hash = "sha256:502d9c0c8008439cfcdef7e251f507fcfdd503b56e8c0c87c3c3e3393953f790"}, +] +simplejson = [ + {file = "simplejson-3.16.0-cp27-cp27m-macosx_10_12_x86_64.whl", hash = "sha256:6c3258ffff58712818a233b9737fe4be943d306c40cf63d14ddc82ba563f483a"}, + {file = "simplejson-3.16.0-cp27-cp27m-win32.whl", hash = "sha256:2fc546e6af49fb45b93bbe878dea4c48edc34083729c0abd09981fe55bdf7f91"}, + {file = "simplejson-3.16.0-cp27-cp27m-win_amd64.whl", hash = "sha256:3b919fc9cf508f13b929a9b274c40786036b31ad28657819b3b9ba44ba651f50"}, + {file = "simplejson-3.16.0-cp33-cp33m-win32.whl", hash = "sha256:3af610ee72efbe644e19d5eaad575c73fb83026192114e5f6719f4901097fce2"}, + {file = "simplejson-3.16.0-cp33-cp33m-win_amd64.whl", hash = "sha256:fb2530b53c28f0d4d84990e945c2ebb470edb469d63e389bf02ff409012fe7c5"}, + {file = "simplejson-3.16.0-cp34-cp34m-win32.whl", hash = "sha256:37e685986cf6f8144607f90340cff72d36acf654f3653a6c47b84c5c38d00df7"}, + {file = "simplejson-3.16.0-cp34-cp34m-win_amd64.whl", hash = "sha256:ee9625fc8ee164902dfbb0ff932b26df112da9f871c32f0f9c1bcf20c350fe2a"}, + {file = "simplejson-3.16.0-cp35-cp35m-macosx_10_12_x86_64.whl", hash = "sha256:354fa32b02885e6dae925f1b5bbf842c333c1e11ea5453ddd67309dc31fdb40a"}, + {file = "simplejson-3.16.0-cp35-cp35m-win32.whl", hash = "sha256:3dd289368bbd064974d9a5961101f080e939cbe051e6689a193c99fb6e9ac89b"}, + {file = "simplejson-3.16.0-cp35-cp35m-win_amd64.whl", hash = "sha256:067a7177ddfa32e1483ba5169ebea1bc2ea27f224853211ca669325648ca5642"}, + {file = "simplejson-3.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:75e3f0b12c28945c08f54350d91e624f8dd580ab74fd4f1bbea54bc6b0165610"}, + {file = "simplejson-3.16.0.tar.gz", hash = "sha256:b1f329139ba647a9548aa05fb95d046b4a677643070dc2afc05fa2e975d09ca5"}, + {file = "simplejson-3.16.0.win-amd64-py2.7.exe", hash = "sha256:495511fe5f10ccf4e3ed4fc0c48318f533654db6c47ecbc970b4ed215c791968"}, + {file = "simplejson-3.16.0.win-amd64-py3.3.exe", hash = "sha256:d8e238f20bcf70063ee8691d4a72162bcec1f4c38f83c93e6851e72ad545dabb"}, + {file = "simplejson-3.16.0.win-amd64-py3.4.exe", hash = "sha256:feadb95170e45f439455354904768608e356c5b174ca30b3d11b0e3f24b5c0df"}, + {file = "simplejson-3.16.0.win-amd64-py3.5.exe", hash = "sha256:65b41a5cda006cfa7c66eabbcf96aa704a6be2a5856095b9e2fd8c293bad2b46"}, + {file = "simplejson-3.16.0.win-amd64-py3.6.exe", hash = "sha256:c206f47cbf9f32b573c9885f0ec813d2622976cf5effcf7e472344bc2e020ac1"}, + {file = "simplejson-3.16.0.win32-py2.7.exe", hash = "sha256:491de7acc423e871a814500eb2dcea8aa66c4a4b1b4825d18f756cdf58e370cb"}, + {file = "simplejson-3.16.0.win32-py3.3.exe", hash = "sha256:79b129fe65fdf3765440f7a73edaffc89ae9e7885d4e2adafe6aa37913a00fbb"}, + {file = "simplejson-3.16.0.win32-py3.4.exe", hash = "sha256:2b8cb601d9ba0381499db719ccc9dfbb2fbd16013f5ff096b1a68a4775576a04"}, + {file = "simplejson-3.16.0.win32-py3.5.exe", hash = "sha256:2c139daf167b96f21542248f8e0a06596c9b9a7a41c162cc5c9ee9f3833c93cd"}, +] +six = [ + {file = "six-1.12.0-py2.py3-none-any.whl", hash = "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c"}, + {file = "six-1.12.0.tar.gz", hash = "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"}, +] +snowballstemmer = [ + {file = "snowballstemmer-1.2.1-py2.py3-none-any.whl", hash = "sha256:9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89"}, + {file = "snowballstemmer-1.2.1.tar.gz", hash = "sha256:919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128"}, +] +sphinx = [ + {file = "Sphinx-2.1.0-py2.py3-none-any.whl", hash = "sha256:2c5becc0fd6706dc0aeb4703f9f1f8a1d1eecacf02e9ac5943cbae48b11e5e42"}, + {file = "Sphinx-2.1.0.tar.gz", hash = "sha256:7a359a91fb04054ec77d68ff97cb8728f8cc322e25f22dc94299d67e0e6a7123"}, +] +sphinx-autodoc-typehints = [ + {file = "sphinx-autodoc-typehints-1.8.0.tar.gz", hash = "sha256:0d968ec3ee4f7fe7695ab6facf5cd2d74d3cea67584277458ad9b2788ebbcc3b"}, + {file = "sphinx_autodoc_typehints-1.8.0-py3-none-any.whl", hash = "sha256:8edca714fd3de8e43467d7e51dd3812fe999f8874408a639f7c38a9e1a5a4eb3"}, +] +sphinx-rtd-theme = [ + {file = "sphinx_rtd_theme-0.4.3-py2.py3-none-any.whl", hash = "sha256:00cf895504a7895ee433807c62094cf1e95f065843bf3acd17037c3e9a2becd4"}, + {file = "sphinx_rtd_theme-0.4.3.tar.gz", hash = "sha256:728607e34d60456d736cc7991fd236afb828b21b82f956c5ea75f94c8414040a"}, +] +sphinxcontrib-applehelp = [ + {file = "sphinxcontrib-applehelp-1.0.1.tar.gz", hash = "sha256:edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897"}, + {file = "sphinxcontrib_applehelp-1.0.1-py2.py3-none-any.whl", hash = "sha256:fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d"}, +] +sphinxcontrib-devhelp = [ + {file = "sphinxcontrib-devhelp-1.0.1.tar.gz", hash = "sha256:6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34"}, + {file = "sphinxcontrib_devhelp-1.0.1-py2.py3-none-any.whl", hash = "sha256:9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981"}, +] +sphinxcontrib-htmlhelp = [ + {file = "sphinxcontrib-htmlhelp-1.0.2.tar.gz", hash = "sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422"}, + {file = "sphinxcontrib_htmlhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7"}, +] +sphinxcontrib-jsmath = [ + {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, + {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, +] +sphinxcontrib-qthelp = [ + {file = "sphinxcontrib-qthelp-1.0.2.tar.gz", hash = "sha256:79465ce11ae5694ff165becda529a600c754f4bc459778778c7017374d4d406f"}, + {file = "sphinxcontrib_qthelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:513049b93031beb1f57d4daea74068a4feb77aa5630f856fcff2e50de14e9a20"}, +] +sphinxcontrib-serializinghtml = [ + {file = "sphinxcontrib-serializinghtml-1.1.3.tar.gz", hash = "sha256:c0efb33f8052c04fd7a26c0a07f1678e8512e0faec19f4aa8f2473a8b81d5227"}, + {file = "sphinxcontrib_serializinghtml-1.1.3-py2.py3-none-any.whl", hash = "sha256:db6615af393650bf1151a6cd39120c29abaf93cc60db8c48eb2dddbfdc3a9768"}, +] +toml = [ + {file = "toml-0.10.0-py2.7.egg", hash = "sha256:f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"}, + {file = "toml-0.10.0-py2.py3-none-any.whl", hash = "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"}, + {file = "toml-0.10.0.tar.gz", hash = "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c"}, +] +urllib3 = [ + {file = "urllib3-1.25.3-py2.py3-none-any.whl", hash = "sha256:b246607a25ac80bedac05c6f282e3cdaf3afb65420fd024ac94435cabe6e18d1"}, + {file = "urllib3-1.25.3.tar.gz", hash = "sha256:dbe59173209418ae49d485b87d1681aefa36252ee85884c31346debd19463232"}, +] +wcwidth = [ + {file = "wcwidth-0.1.7-py2.py3-none-any.whl", hash = "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c"}, + {file = "wcwidth-0.1.7.tar.gz", hash = "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e"}, +] +zipp = [ + {file = "zipp-0.5.1-py2.py3-none-any.whl", hash = "sha256:8c1019c6aad13642199fbe458275ad6a84907634cc9f0989877ccc4a2840139d"}, + {file = "zipp-0.5.1.tar.gz", hash = "sha256:ca943a7e809cc12257001ccfb99e3563da9af99d52f261725e96dfe0f9275bc3"}, +] diff --git a/pyproject.toml b/pyproject.toml index 2e5e24cb..5c4e7d82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,5 +40,5 @@ recommonmark = "^0.6.0" sphinx-autodoc-typehints = "^1.8" [build-system] -requires = ["poetry>=0.12"] +requires = ["poetry>=1.0"] build-backend = "poetry.masonry.api" From df05f32a980e14f8cf8cb99bf0c6bbc1a27cdff8 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Sat, 21 Dec 2019 18:27:09 -0500 Subject: [PATCH 211/632] CI: Linting via Github Actions Lint and formatting checks for PRs or Pushes on master or release-* branches --- .github/workflows/ci.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..cfc3b7da --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: + - master + - release-* + pull_request: + branches: + - master + - release-* + +jobs: + Lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Install Python + uses: actions/setup-python@v1.1.1 + with: + python-version: 3.6 + - name: Install Poetry + uses: dschep/install-poetry-action@v1.2 + - name: Install dependencies + run: poetry install + - name: Run flake8 + run: poetry run flake8 . + - name: Run black + run: poetry run black --check . From bbcfaf2bb764ef5d2e5acb75d781f5b360846ed7 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Sun, 22 Dec 2019 13:21:25 -0500 Subject: [PATCH 212/632] CI: Test via Github Actions Test against all supported Python versions (3.6+) --- .github/workflows/ci.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfc3b7da..ff909c19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,3 +27,21 @@ jobs: run: poetry run flake8 . - name: Run black run: poetry run black --check . + + Test: + strategy: + matrix: + python_version: ['3.6', '3.7', '3.8'] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Install Python + uses: actions/setup-python@v1.1.1 + with: + python-version: ${{ matrix.python_version }} + - name: Install Poetry + uses: dschep/install-poetry-action@v1.2 + - name: Install dependencies + run: poetry install + - name: Run pytest + run: poetry run pytest From 251e547a7986deed83fbcba800dd3e05675baf26 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Sun, 22 Dec 2019 13:23:51 -0500 Subject: [PATCH 213/632] CI: Doc checks via Github actions Docs should build without warnings/errors --- .github/workflows/ci.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff909c19..04a31d75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,3 +45,18 @@ jobs: run: poetry install - name: Run pytest run: poetry run pytest + + Docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Install Python + uses: actions/setup-python@v1.1.1 + with: + python-version: 3.6 + - name: Install Poetry + uses: dschep/install-poetry-action@v1.2 + - name: Install dependencies + run: poetry install + - name: Build docs + run: poetry run sphinx-build -b html docs docs/_build -W From 6e464c13afa34a130167c07ff7b52fa821f6b5fc Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Sun, 22 Dec 2019 10:19:37 -0500 Subject: [PATCH 214/632] Python 3.8 compatibility: Upgrade dependencies - numpy (depended on by pandas): 1.17.0 -> 1.17.4 - pluggy (depended on by pytest): 0.12.0 -> 0.13.1 --- poetry.lock | 145 +++++++++++++++++++++++++++---------------------- pyproject.toml | 4 +- 2 files changed, 83 insertions(+), 66 deletions(-) diff --git a/poetry.lock b/poetry.lock index 3f1bbeb1..15b320d5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -17,6 +17,7 @@ version = "1.4.3" [[package]] category = "dev" description = "Atomic file writes." +marker = "sys_platform == \"win32\"" name = "atomicwrites" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" @@ -187,16 +188,18 @@ version = "1.1.0" [[package]] category = "dev" description = "Read metadata from Python packages" +marker = "python_version < \"3.8\"" name = "importlib-metadata" optional = false -python-versions = ">=2.7,!=3.0,!=3.1,!=3.2,!=3.3" -version = "0.17" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +version = "1.3.0" [package.dependencies] zipp = ">=0.5" [package.extras] -docs = ["sphinx", "docutils (0.12)", "rst.linker"] +docs = ["sphinx", "rst.linker"] +testing = ["packaging", "importlib-resources"] [[package]] category = "dev" @@ -231,11 +234,10 @@ version = "0.6.1" [[package]] category = "dev" description = "More routines for operating on iterables, beyond itertools" -marker = "python_version > \"2.7\"" name = "more-itertools" optional = false -python-versions = ">=3.4" -version = "7.0.0" +python-versions = ">=3.5" +version = "8.0.2" [[package]] category = "dev" @@ -243,7 +245,7 @@ description = "NumPy is the fundamental package for array computing with Python. name = "numpy" optional = false python-versions = ">=3.5" -version = "1.17.0" +version = "1.17.4" [[package]] category = "dev" @@ -263,7 +265,7 @@ description = "Powerful data structures for data analysis, time series, and stat name = "pandas" optional = false python-versions = ">=3.5.3" -version = "0.25.0" +version = "0.25.3" [package.dependencies] numpy = ">=1.13.3" @@ -279,10 +281,12 @@ description = "plugin and hook calling mechanisms for python" name = "pluggy" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "0.12.0" +version = "0.13.1" [package.dependencies] -importlib-metadata = ">=0.12" +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12" [package.extras] dev = ["pre-commit", "tox"] @@ -332,34 +336,33 @@ category = "dev" description = "pytest: simple powerful testing with Python" name = "pytest" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "4.6.2" +python-versions = ">=3.5" +version = "5.3.2" [package.dependencies] atomicwrites = ">=1.0" attrs = ">=17.4.0" colorama = "*" -importlib-metadata = ">=0.12" +more-itertools = ">=4.0.0" packaging = "*" pluggy = ">=0.12,<1.0" py = ">=1.5.0" -six = ">=1.10.0" wcwidth = "*" -[package.dependencies.more-itertools] -python = ">=2.8" -version = ">=4.0.0" +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12" [package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "nose", "requests", "mock"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] [[package]] category = "dev" description = "Extensions to the standard Python datetime module" name = "python-dateutil" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -version = "2.8.0" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +version = "2.8.1" [package.dependencies] six = ">=1.5" @@ -597,17 +600,21 @@ version = "0.1.7" [[package]] category = "dev" description = "Backport of pathlib-compatible object wrapper for zip files" +marker = "python_version < \"3.8\"" name = "zipp" optional = false python-versions = ">=2.7" -version = "0.5.1" +version = "0.6.0" + +[package.dependencies] +more-itertools = "*" [package.extras] docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["pathlib2", "contextlib2", "unittest2"] [metadata] -content-hash = "4f1e35032544d1e693bd92e4e5d92f4dd1048eff062b551effb3d8c1c60d3955" +content-hash = "53ec8ec3ea9709d54c9579358a0b68b4779d9f4779c26ee339713fb91154be86" python-versions = "^3.6" [metadata.files] @@ -688,8 +695,8 @@ imagesize = [ {file = "imagesize-1.1.0.tar.gz", hash = "sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5"}, ] importlib-metadata = [ - {file = "importlib_metadata-0.17-py2.py3-none-any.whl", hash = "sha256:df1403cd3aebeb2b1dcd3515ca062eecb5bd3ea7611f18cba81130c68707e879"}, - {file = "importlib_metadata-0.17.tar.gz", hash = "sha256:a9f185022cfa69e9ca5f7eabfd5a58b689894cb78a11e3c8c89398a8ccbb8e7f"}, + {file = "importlib_metadata-1.3.0-py2.py3-none-any.whl", hash = "sha256:d95141fbfa7ef2ec65cfd945e2af7e5a6ddbd7c8d9a25e66ff3be8e3daf9f60f"}, + {file = "importlib_metadata-1.3.0.tar.gz", hash = "sha256:073a852570f92da5f744a3472af1b61e28e9f78ccf0c9117658dc32b15de7b45"}, ] jinja2 = [ {file = "Jinja2-2.10.1-py2.py3-none-any.whl", hash = "sha256:14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b"}, @@ -730,50 +737,60 @@ mccabe = [ {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] more-itertools = [ - {file = "more-itertools-7.0.0.tar.gz", hash = "sha256:c3e4748ba1aad8dba30a4886b0b1a2004f9a863837b8654e7059eebf727afa5a"}, - {file = "more_itertools-7.0.0-py3-none-any.whl", hash = "sha256:2112d2ca570bb7c3e53ea1a35cd5df42bb0fd10c45f0fb97178679c3c03d64c7"}, + {file = "more-itertools-8.0.2.tar.gz", hash = "sha256:b84b238cce0d9adad5ed87e745778d20a3f8487d0f0cb8b8a586816c7496458d"}, + {file = "more_itertools-8.0.2-py3-none-any.whl", hash = "sha256:c833ef592a0324bcc6a60e48440da07645063c453880c9477ceb22490aec1564"}, ] numpy = [ - {file = "numpy-1.17.0-cp35-cp35m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:910d2272403c2ea8a52d9159827dc9f7c27fb4b263749dca884e2e4a8af3b302"}, - {file = "numpy-1.17.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:9ce8300950f2f1d29d0e49c28ebfff0d2f1e2a7444830fbb0b913c7c08f31511"}, - {file = "numpy-1.17.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7724e9e31ee72389d522b88c0d4201f24edc34277999701ccd4a5392e7d8af61"}, - {file = "numpy-1.17.0-cp35-cp35m-win32.whl", hash = "sha256:0cdd229a53d2720d21175012ab0599665f8c9588b3b8ffa6095dd7b90f0691dd"}, - {file = "numpy-1.17.0-cp35-cp35m-win_amd64.whl", hash = "sha256:5adfde7bd3ee4864536e230bcab1c673f866736698724d5d28c11a4d63672658"}, - {file = "numpy-1.17.0-cp36-cp36m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:464b1c48baf49e8505b1bb754c47a013d2c305c5b14269b5c85ea0625b6a988a"}, - {file = "numpy-1.17.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:eb0fc4a492cb896346c9e2c7a22eae3e766d407df3eb20f4ce027f23f76e4c54"}, - {file = "numpy-1.17.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:9588c6b4157f493edeb9378788dcd02cb9e6a6aeaa518b511a1c79d06cbd8094"}, - {file = "numpy-1.17.0-cp36-cp36m-win32.whl", hash = "sha256:03e311b0a4c9f5755da7d52161280c6a78406c7be5c5cc7facfbcebb641efb7e"}, - {file = "numpy-1.17.0-cp36-cp36m-win_amd64.whl", hash = "sha256:c3ab2d835b95ccb59d11dfcd56eb0480daea57cdf95d686d22eff35584bc4554"}, - {file = "numpy-1.17.0-cp37-cp37m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:f4e4612de60a4f1c4d06c8c2857cdcb2b8b5289189a12053f37d3f41f06c60d0"}, - {file = "numpy-1.17.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:312bb18e95218bedc3563f26fcc9c1c6bfaaf9d453d15942c0839acdd7e4c473"}, - {file = "numpy-1.17.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:8d36f7c53ae741e23f54793ffefb2912340b800476eb0a831c6eb602e204c5c4"}, - {file = "numpy-1.17.0-cp37-cp37m-win32.whl", hash = "sha256:ec0c56eae6cee6299f41e780a0280318a93db519bbb2906103c43f3e2be1206c"}, - {file = "numpy-1.17.0-cp37-cp37m-win_amd64.whl", hash = "sha256:be39cca66cc6806652da97103605c7b65ee4442c638f04ff064a7efd9a81d50a"}, - {file = "numpy-1.17.0.zip", hash = "sha256:951fefe2fb73f84c620bec4e001e80a80ddaa1b84dce244ded7f1e0cbe0ed34a"}, + {file = "numpy-1.17.4-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:ede47b98de79565fcd7f2decb475e2dcc85ee4097743e551fe26cfc7eb3ff143"}, + {file = "numpy-1.17.4-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:43bb4b70585f1c2d153e45323a886839f98af8bfa810f7014b20be714c37c447"}, + {file = "numpy-1.17.4-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c7354e8f0eca5c110b7e978034cd86ed98a7a5ffcf69ca97535445a595e07b8e"}, + {file = "numpy-1.17.4-cp35-cp35m-win32.whl", hash = "sha256:64874913367f18eb3013b16123c9fed113962e75d809fca5b78ebfbb73ed93ba"}, + {file = "numpy-1.17.4-cp35-cp35m-win_amd64.whl", hash = "sha256:6ca4000c4a6f95a78c33c7dadbb9495c10880be9c89316aa536eac359ab820ae"}, + {file = "numpy-1.17.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:75fd817b7061f6378e4659dd792c84c0b60533e867f83e0d1e52d5d8e53df88c"}, + {file = "numpy-1.17.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:7d81d784bdbed30137aca242ab307f3e65c8d93f4c7b7d8f322110b2e90177f9"}, + {file = "numpy-1.17.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe39f5fd4103ec4ca3cb8600b19216cd1ff316b4990f4c0b6057ad982c0a34d5"}, + {file = "numpy-1.17.4-cp36-cp36m-win32.whl", hash = "sha256:e467c57121fe1b78a8f68dd9255fbb3bb3f4f7547c6b9e109f31d14569f490c3"}, + {file = "numpy-1.17.4-cp36-cp36m-win_amd64.whl", hash = "sha256:8d0af8d3664f142414fd5b15cabfd3b6cc3ef242a3c7a7493257025be5a6955f"}, + {file = "numpy-1.17.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9679831005fb16c6df3dd35d17aa31dc0d4d7573d84f0b44cc481490a65c7725"}, + {file = "numpy-1.17.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:acbf5c52db4adb366c064d0b7c7899e3e778d89db585feadd23b06b587d64761"}, + {file = "numpy-1.17.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:3d52298d0be333583739f1aec9026f3b09fdfe3ddf7c7028cb16d9d2af1cca7e"}, + {file = "numpy-1.17.4-cp37-cp37m-win32.whl", hash = "sha256:475963c5b9e116c38ad7347e154e5651d05a2286d86455671f5b1eebba5feb76"}, + {file = "numpy-1.17.4-cp37-cp37m-win_amd64.whl", hash = "sha256:0c0763787133dfeec19904c22c7e358b231c87ba3206b211652f8cbe1241deb6"}, + {file = "numpy-1.17.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:683828e50c339fc9e68720396f2de14253992c495fdddef77a1e17de55f1decc"}, + {file = "numpy-1.17.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e2e9d8c87120ba2c591f60e32736b82b67f72c37ba88a4c23c81b5b8fa49c018"}, + {file = "numpy-1.17.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:a8f67ebfae9f575d85fa859b54d3bdecaeece74e3274b0b5c5f804d7ca789fe1"}, + {file = "numpy-1.17.4-cp38-cp38-win32.whl", hash = "sha256:0a7a1dd123aecc9f0076934288ceed7fd9a81ba3919f11a855a7887cbe82a02f"}, + {file = "numpy-1.17.4-cp38-cp38-win_amd64.whl", hash = "sha256:ada4805ed51f5bcaa3a06d3dd94939351869c095e30a2b54264f5a5004b52170"}, + {file = "numpy-1.17.4.zip", hash = "sha256:f58913e9227400f1395c7b800503ebfdb0772f1c33ff8cb4d6451c06cabdf316"}, ] packaging = [ {file = "packaging-19.0-py2.py3-none-any.whl", hash = "sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3"}, {file = "packaging-19.0.tar.gz", hash = "sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af"}, ] pandas = [ - {file = "pandas-0.25.0-cp35-cp35m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:32c44e5b628c48ba17703f734d59f369d4cdcb4239ef26047d6c8a8bfda29a6b"}, - {file = "pandas-0.25.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:df7e1933a0b83920769611c5d6b9a1bf301e3fa6a544641c6678c67621fe9843"}, - {file = "pandas-0.25.0-cp35-cp35m-win32.whl", hash = "sha256:cfb862aa37f4dd5be0730731fdb8185ac935aba8b51bf3bd035658111c9ee1c9"}, - {file = "pandas-0.25.0-cp35-cp35m-win_amd64.whl", hash = "sha256:b932b127da810fef57d427260dde1ad54542c136c44b227a1e367551bb1a684b"}, - {file = "pandas-0.25.0-cp36-cp36m-macosx_10_9_x86_64.macosx_10_10_x86_64.whl", hash = "sha256:3b9f7dcee6744d9dcdd53bce19b91d20b4311bf904303fa00ef58e7df398e901"}, - {file = "pandas-0.25.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:074a032f99bb55d178b93bd98999c971542f19317829af08c99504febd9e9b8b"}, - {file = "pandas-0.25.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:9023972a92073a495eba1380824b197ad1737550fe1c4ef8322e65fe58662888"}, - {file = "pandas-0.25.0-cp36-cp36m-win32.whl", hash = "sha256:20f1728182b49575c2f6f681b3e2af5fac9e84abdf29488e76d569a7969b362e"}, - {file = "pandas-0.25.0-cp36-cp36m-win_amd64.whl", hash = "sha256:58f9ef68975b9f00ba96755d5702afdf039dea9acef6a0cfd8ddcde32918a79c"}, - {file = "pandas-0.25.0-cp37-cp37m-macosx_10_9_x86_64.macosx_10_10_x86_64.whl", hash = "sha256:2745ba6e16c34d13d765c3657bb64fa20a0e2daf503e6216a36ed61770066179"}, - {file = "pandas-0.25.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:9d151bfb0e751e2c987f931c57792871c8d7ff292bcdfcaa7233012c367940ee"}, - {file = "pandas-0.25.0-cp37-cp37m-win32.whl", hash = "sha256:de7ecb4b120e98b91e8a2a21f186571266a8d1faa31d92421e979c7ca67d8e5c"}, - {file = "pandas-0.25.0-cp37-cp37m-win_amd64.whl", hash = "sha256:544f2033250980fb6f069ce4a960e5f64d99b8165d01dc39afd0b244eeeef7d7"}, - {file = "pandas-0.25.0.tar.gz", hash = "sha256:914341ad2d5b1ea522798efa4016430b66107d05781dbfe7cf05eba8f37df995"}, + {file = "pandas-0.25.3-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:df8864824b1fe488cf778c3650ee59c3a0d8f42e53707de167ba6b4f7d35f133"}, + {file = "pandas-0.25.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:7458c48e3d15b8aaa7d575be60e1e4dd70348efcd9376656b72fecd55c59a4c3"}, + {file = "pandas-0.25.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:61741f5aeb252f39c3031d11405305b6d10ce663c53bc3112705d7ad66c013d0"}, + {file = "pandas-0.25.3-cp35-cp35m-win32.whl", hash = "sha256:adc3d3a3f9e59a38d923e90e20c4922fc62d1e5a03d083440468c6d8f3f1ae0a"}, + {file = "pandas-0.25.3-cp35-cp35m-win_amd64.whl", hash = "sha256:975c461accd14e89d71772e89108a050fa824c0b87a67d34cedf245f6681fc17"}, + {file = "pandas-0.25.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ee50c2142cdcf41995655d499a157d0a812fce55c97d9aad13bc1eef837ed36c"}, + {file = "pandas-0.25.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:4545467a637e0e1393f7d05d61dace89689ad6d6f66f267f86fff737b702cce9"}, + {file = "pandas-0.25.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:bbe3eb765a0b1e578833d243e2814b60c825b7fdbf4cdfe8e8aae8a08ed56ecf"}, + {file = "pandas-0.25.3-cp36-cp36m-win32.whl", hash = "sha256:8153705d6545fd9eb6dd2bc79301bff08825d2e2f716d5dced48daafc2d0b81f"}, + {file = "pandas-0.25.3-cp36-cp36m-win_amd64.whl", hash = "sha256:26382aab9c119735908d94d2c5c08020a4a0a82969b7e5eefb92f902b3b30ad7"}, + {file = "pandas-0.25.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:00dff3a8e337f5ed7ad295d98a31821d3d0fe7792da82d78d7fd79b89c03ea9d"}, + {file = "pandas-0.25.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e45055c30a608076e31a9fcd780a956ed3b1fa20db61561b8d88b79259f526f7"}, + {file = "pandas-0.25.3-cp37-cp37m-win32.whl", hash = "sha256:255920e63850dc512ce356233081098554d641ba99c3767dde9e9f35630f994b"}, + {file = "pandas-0.25.3-cp37-cp37m-win_amd64.whl", hash = "sha256:22361b1597c8c2ffd697aa9bf85423afa9e1fcfa6b1ea821054a244d5f24d75e"}, + {file = "pandas-0.25.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9962957a27bfb70ab64103d0a7b42fa59c642fb4ed4cb75d0227b7bb9228535d"}, + {file = "pandas-0.25.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:78bf638993219311377ce9836b3dc05f627a666d0dbc8cec37c0ff3c9ada673b"}, + {file = "pandas-0.25.3-cp38-cp38-win32.whl", hash = "sha256:6a3ac2c87e4e32a969921d1428525f09462770c349147aa8e9ab95f88c71ec71"}, + {file = "pandas-0.25.3-cp38-cp38-win_amd64.whl", hash = "sha256:33970f4cacdd9a0ddb8f21e151bfb9f178afb7c36eb7c25b9094c02876f385c2"}, + {file = "pandas-0.25.3.tar.gz", hash = "sha256:52da74df8a9c9a103af0a72c9d5fdc8e0183a90884278db7f386b5692a2220a4"}, ] pluggy = [ - {file = "pluggy-0.12.0-py2.py3-none-any.whl", hash = "sha256:b9817417e95936bf75d85d3f8767f7df6cdde751fc40aed3bb3074cbcb77757c"}, - {file = "pluggy-0.12.0.tar.gz", hash = "sha256:0825a152ac059776623854c1543d65a4ad408eb3d33ee114dff91e57ec6ae6fc"}, + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] py = [ {file = "py-1.8.0-py2.py3-none-any.whl", hash = "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa"}, @@ -796,12 +813,12 @@ pyparsing = [ {file = "pyparsing-2.4.0.tar.gz", hash = "sha256:1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a"}, ] pytest = [ - {file = "pytest-4.6.2-py2.py3-none-any.whl", hash = "sha256:6032845e68a17a96e8da3088037f899b56357769a724122056265ca2ea1890ee"}, - {file = "pytest-4.6.2.tar.gz", hash = "sha256:bea27a646a3d74cbbcf8d3d4a06b2dfc336baf3dc2cc85cf70ad0157e73e8322"}, + {file = "pytest-5.3.2-py3-none-any.whl", hash = "sha256:e41d489ff43948babd0fad7ad5e49b8735d5d55e26628a58673c39ff61d95de4"}, + {file = "pytest-5.3.2.tar.gz", hash = "sha256:6b571215b5a790f9b41f19f3531c53a45cf6bb8ef2988bc1ff9afb38270b25fa"}, ] python-dateutil = [ - {file = "python-dateutil-2.8.0.tar.gz", hash = "sha256:c89805f6f4d64db21ed966fda138f8a5ed7a4fdbc1a8ee329ce1b74e3c74da9e"}, - {file = "python_dateutil-2.8.0-py2.py3-none-any.whl", hash = "sha256:7e6584c74aeed623791615e26efd690f29817a27c73085b78e4bad02493df2fb"}, + {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, + {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, ] pytz = [ {file = "pytz-2019.1-py2.py3-none-any.whl", hash = "sha256:303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda"}, @@ -900,6 +917,6 @@ wcwidth = [ {file = "wcwidth-0.1.7.tar.gz", hash = "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e"}, ] zipp = [ - {file = "zipp-0.5.1-py2.py3-none-any.whl", hash = "sha256:8c1019c6aad13642199fbe458275ad6a84907634cc9f0989877ccc4a2840139d"}, - {file = "zipp-0.5.1.tar.gz", hash = "sha256:ca943a7e809cc12257001ccfb99e3563da9af99d52f261725e96dfe0f9275bc3"}, + {file = "zipp-0.6.0-py2.py3-none-any.whl", hash = "sha256:f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335"}, + {file = "zipp-0.6.0.tar.gz", hash = "sha256:3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e"}, ] diff --git a/pyproject.toml b/pyproject.toml index 5c4e7d82..d58a0588 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,14 +30,14 @@ dataclasses = "^0.6.0" Sphinx = "^2.1" responses = "^0.10.6" flake8-import-order = "^0.18.1" -pytest = "^4.6" black = {version = "^19.3b0",allow-prereleases = true} flake8 = "^3.7" toml = "^0.10.0" sphinx_rtd_theme = "^0.4.3" -pandas = "^0.25.0" recommonmark = "^0.6.0" sphinx-autodoc-typehints = "^1.8" +pandas = "^0.25.3" +pytest = "^5.3.2" [build-system] requires = ["poetry>=1.0"] From 901a65e1e090919046a5f1b0722cf1181d02b0a3 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Sun, 22 Dec 2019 16:14:55 -0500 Subject: [PATCH 215/632] Release via Github Actions Trigger PyPI publish when Github release is published Updated release process to point to Github Actions (previously refered to Travis CI). --- .github/workflows/release.yml | 23 +++++++++++++++++++++++ RELEASE.md | 5 +---- 2 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..cf12830c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,23 @@ +name: Release + +on: + release: + types: [published] + +jobs: + name: Publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Install Python + uses: actions/setup-python@v1.1.1 + with: + python-version: 3.6 + - name: Install Poetry + uses: dschep/install-poetry-action@v1.2 + - name: Install dependencies + run: poetry install --no-dev + - name: Publish to PyPI + env: + POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }} + run: poetry publish diff --git a/RELEASE.md b/RELEASE.md index a3393209..0fa661a2 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -56,11 +56,8 @@ Create the release. This should also implicitly create a tag for the release und # 5. Check on published artifacts -We use Travis CI as our Continuous Integration (CI) solution. +CI is wired to [publish releases to PyPI for any published Github releases](https://github.com/Datatamer/tamr-client/blob/master/.github/workflows/release.yml). -CI is wired to ["deploy"](https://github.com/Datatamer/tamr-client/blob/master/.travis.yml#L14) (a.k.a. publish) releases to PyPI for any tags that look like a semantic version number e.g. `0.3.0`. So CI should handle publishing for you. - -Check that CI tests passed. Check that CI successfully published the release version to [PyPI](https://pypi.org/project/tamr-unify-client/#history). On the `master` branch, add release date for this release in the `CHANGELOG.md`. From e6d35b5a3846d6437b323791c8f8408b86f3555f Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Sun, 22 Dec 2019 16:18:36 -0500 Subject: [PATCH 216/632] Replace Travis CI with Github Actions Github Actions configured with feature parity to .travis.yml --- .travis.yml | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 770ed84c..00000000 --- a/.travis.yml +++ /dev/null @@ -1,23 +0,0 @@ -dist: xenial -language: python -python: -- 3.6 -before_install: -- pip install poetry -install: -- poetry install -script: -- poetry run black --check . -- poetry run flake8 . -- poetry run pytest tests -- cd docs && poetry run sphinx-build -b html . _build -W -before_deploy: -- poetry build -- poetry config http-basic.pypi $PYPI_USERNAME $PYPI_PASSWORD -deploy: - provider: script - script: poetry publish - skip_cleanup: true - on: - tags: true - condition: "$TRAVIS_TAG =~ ^[0-9]+\\.[0-9]+\\.[0-9]+$" From 00e2dcc309c41fe97bbbb16b2775016c2bb20105 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Sun, 22 Dec 2019 16:40:44 -0500 Subject: [PATCH 217/632] CI/CD: fix Github Actions workflow syntax error --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf12830c..313557cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ on: types: [published] jobs: - name: Publish + Publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 From 30cc38afc40de5c15e83767472253da608a55ceb Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Sun, 22 Dec 2019 17:01:27 -0500 Subject: [PATCH 218/632] Fix build badge Reference Github Actions, not Travis CI --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 134096e9..980e1ea8 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Programmatically 💻 interact with Tamr using Python 🐍 [![Version](https://img.shields.io/pypi/v/tamr-unify-client.svg?style=flat-square)](https://pypi.org/project/tamr-unify-client/) [![Documentation Status](https://readthedocs.org/projects/tamr-client/badge/?version=stable&style=flat-square)](https://tamr-client.readthedocs.io/en/stable/?badge=stable) -[![Build Status](https://img.shields.io/travis/Datatamer/tamr-client.svg?style=flat-square)](https://travis-ci.org/Datatamer/tamr-client) +[![Build Status](https://img.shields.io/github/workflow/status/Datatamer/tamr-client/CI?&style=flat-square)](https://github.com/Datatamer/tamr-client/actions?query=workflow%3ACI) ![Supported Python Versions](https://img.shields.io/pypi/pyversions/tamr-unify-client.svg?style=flat-square) [![License](https://img.shields.io/pypi/l/tamr-unify-client.svg?style=flat-square)](LICENSE) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg?style=flat-square)](https://github.com/ambv/black) From e6fb8a1604d1809135b4b3afb5b79a743a564177 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Sun, 22 Dec 2019 17:01:50 -0500 Subject: [PATCH 219/632] Simplify/shorten features list in README --- README.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 980e1ea8..84ea66cc 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,8 @@ pip install tamr-unify-client - Train Tamr's machine learning models - Generate predictions from trained models - 🔒 Authenticate with Tamr -- 📥 Fetch resources (e.g projects) by resource ID (e.g. `"1"`) -- 📝 Read resource metadata -- 🔁 Iterate over collections -- ⚠️ Advanced - - Logging for API requests/responses - - Call custom/arbitrary API endpoints + +For more see the [official docs](https://tamr-client.readthedocs.io/en/stable/). ## Maintainers From 1bebe5e9768e2b415eb0ac27a01a6999000a2abf Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 26 Dec 2019 11:12:15 -0500 Subject: [PATCH 220/632] Declare support for Python 3.8 CI tests against Python 3.8 now. --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d58a0588..87e96964 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,8 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7" + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8" ] [tool.poetry.dependencies] From 83fc2e98b381719ce287aff4231493099e6aa066 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 26 Dec 2019 11:14:06 -0500 Subject: [PATCH 221/632] Python 3.6+ support: CHANGELOG entry --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80ed6dde..749aa6da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ - [#309](https://github.com/Datatamer/tamr-client/issues/309) Migrate `SubAttribute` to use `@dataclass(frozen=True)`. `SubAttribute.__init__` constructor replaced with the one generated by `@dataclass`. `SubAttribute`s should be constructed via the `SubAttribute.from_json` static method. - [#307](https://github.com/Datatamer/tamr-client/issues/307) Logging improvements: (1) Use standard logging best practices (2) log response body for responses containing HTTP error codes. Previous way to configure logging (via `Client.logger` and `Client.log_entry`) have been replaced. See [User Guide > Logging](https://tamr-client.readthedocs.io/en/latest/user-guide/logging.html). + **NEW FEATURES** + - [#295](https://github.com/Datatamer/tamr-client/issues/295) Official support for Python 3.6+. CI now tests against Python 3.6, 3.7, 3.8 . + **BUG FIXES** - [#293](https://github.com/Datatamer/tamr-client/issues/293) Better handling for HTTP 204 on already up-to-date operations From 2d8c49124609bcc8bbde6c9968afe41213b3d880 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 24 Jan 2020 09:22:39 -0500 Subject: [PATCH 222/632] Remove Code of Conduct We want to keep the source code public, but have no current plans of accepting PRs from non-Tamrs. --- CODE_OF_CONDUCT.md | 76 -------------------------------------- README.md | 1 - docs/contributor-guide.rst | 5 --- 3 files changed, 82 deletions(-) delete mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 06e3f5f1..00000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,76 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, sex characteristics, gender identity and expression, -level of experience, education, socio-economic status, nationality, personal -appearance, race, religion, or sexual identity and orientation. - -## Our Standards - -Examples of behavior that contributes to creating a positive environment -include: - -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members - -Examples of unacceptable behavior by participants include: - -* The use of sexualized language or imagery and unwelcome sexual attention or - advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic - address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Our Responsibilities - -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. - -Project maintainers have the right and responsibility to remove, edit, or -reject comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct, or to ban temporarily or -permanently any contributor for other behaviors that they deem inappropriate, -threatening, offensive, or harmful. - -## Scope - -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project e-mail -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at support@tamr.com . All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. - -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see -https://www.contributor-covenant.org/faq diff --git a/README.md b/README.md index 84ea66cc..5edfd857 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ Programmatically 💻 interact with Tamr using Python 🐍 *Quick links:* **[Docs](https://tamr-client.readthedocs.io/en/stable/)** | **[Contributing](https://tamr-client.readthedocs.io/en/stable/contributor-guide.html)** | -**[Code of Conduct](https://github.com/Datatamer/tamr-client/blob/master/CODE_OF_CONDUCT.md)** | **[Change Log](https://github.com/Datatamer/tamr-client/blob/master/CHANGELOG.md)** | **[License](https://github.com/Datatamer/tamr-client/blob/master/LICENSE)** diff --git a/docs/contributor-guide.rst b/docs/contributor-guide.rst index 85dcfbdd..0e16a059 100644 --- a/docs/contributor-guide.rst +++ b/docs/contributor-guide.rst @@ -1,11 +1,6 @@ Contributor Guide ================= -Code of Conduct ---------------- - -See `CODE_OF_CONDUCT.md `_ - .. _bug-reports-feature-requests: 🐛 Bug Reports / 🙋 Feature Requests From 1ba6ee0577207648aa6e46ba136fc186001c3f60 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 24 Jan 2020 09:29:13 -0500 Subject: [PATCH 223/632] Version bump for release --- CHANGELOG.md | 4 +++- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 749aa6da..c14f8283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ -## 0.10.0-dev +## 0.11.0-dev + +## 0.10.0 **BREAKING CHANGES** - [#309](https://github.com/Datatamer/tamr-client/issues/309) Migrate `SubAttribute` to use `@dataclass(frozen=True)`. `SubAttribute.__init__` constructor replaced with the one generated by `@dataclass`. `SubAttribute`s should be constructed via the `SubAttribute.from_json` static method. - [#307](https://github.com/Datatamer/tamr-client/issues/307) Logging improvements: (1) Use standard logging best practices (2) log response body for responses containing HTTP error codes. Previous way to configure logging (via `Client.logger` and `Client.log_entry`) have been replaced. See [User Guide > Logging](https://tamr-client.readthedocs.io/en/latest/user-guide/logging.html). diff --git a/pyproject.toml b/pyproject.toml index 87e96964..275cbde7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "tamr-unify-client" -version = "0.10.0-dev" +version = "0.11.0-dev" description = "Python Client for the Tamr API" license = "Apache-2.0" authors = ["Pedro Cattori "] From e2f4059bb5ecf5fc2ccf6547d5cee5ec51b6f1a8 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 24 Jan 2020 09:53:24 -0500 Subject: [PATCH 224/632] fix(ci): build poetry artifacts BEFORE publishing --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 313557cd..30c880fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,4 +20,4 @@ jobs: - name: Publish to PyPI env: POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }} - run: poetry publish + run: poetry publish --build From 64317c318f401a72e03b61266dc1bf03a7f761e6 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 29 Jan 2020 11:39:30 -0500 Subject: [PATCH 225/632] Update requests docs URL Ownership of requests library changed, and URL for docs also changed. --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index df8ea1f8..e7848ccf 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -61,7 +61,7 @@ autosectionlabel_prefix_document = True intersphinx_mapping = { "https://docs.python.org/": None, - "requests": ("https://requests.kennethreitz.org/en/master/", None), + "requests": ("https://requests.readthedocs.io/en/master/", None), "pandas": ("https://pandas.pydata.org/pandas-docs/stable", None), } From c3753153d7aa4c3c078d4f3600c712a139fdb65d Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 27 Jan 2020 22:49:28 -0500 Subject: [PATCH 226/632] Attaching auth to session No need to explicitly pass in auth for every request --- tamr_unify_client/client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tamr_unify_client/client.py b/tamr_unify_client/client.py index bb3fa62f..70009243 100644 --- a/tamr_unify_client/client.py +++ b/tamr_unify_client/client.py @@ -76,6 +76,7 @@ def __init__( self.port = port self.base_path = base_path self.session = session or requests.Session() + self.session.auth = auth self._projects = ProjectCollection(self) self._datasets = DatasetCollection(self) @@ -108,7 +109,7 @@ def request(self, method: str, endpoint: str, **kwargs) -> requests.Response: HTTP response from the Tamr server """ url = urljoin(self.origin + self.base_path, endpoint) - response = self.session.request(method, url, auth=self.auth, **kwargs) + response = self.session.request(method, url, **kwargs) logger.info( f"{response.request.method} {response.url} : {response.status_code}" From ffc4edb2b690a0a467c7e3340f3269217a85bf0a Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 28 Jan 2020 09:36:07 -0500 Subject: [PATCH 227/632] Remove unnecessary quotes from Python versions --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04a31d75..ebc3d262 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: Test: strategy: matrix: - python_version: ['3.6', '3.7', '3.8'] + python_version: [3.6, 3.7, 3.8] runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 From d93b8ceade678fee3bdf30c2541785514e4546f8 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 27 Jan 2020 22:47:39 -0500 Subject: [PATCH 228/632] response module response.successful function Monkey-patching requests.Response.successful --- tamr_unify_client/client.py | 27 +-------------------------- tamr_unify_client/response.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 26 deletions(-) create mode 100644 tamr_unify_client/response.py diff --git a/tamr_unify_client/client.py b/tamr_unify_client/client.py index 70009243..739b88c7 100644 --- a/tamr_unify_client/client.py +++ b/tamr_unify_client/client.py @@ -8,36 +8,11 @@ from tamr_unify_client.dataset.collection import DatasetCollection from tamr_unify_client.project.collection import ProjectCollection +import tamr_unify_client.response # monkey-patch requests.Response.successful logger = logging.getLogger(__name__) -def successful(response: requests.Response) -> requests.Response: - """Ensure response does not contain an HTTP error. - - Delegates to :func:`requests.Response.raise_for_status` - - Returns: - The response being checked. - - Raises: - requests.exceptions.HTTPError: If an HTTP error is encountered. - """ - try: - response.raise_for_status() - except requests.exceptions.HTTPError as e: - r = e.response - logger.error( - f"Encountered HTTP error code {r.status_code}. Response body: {r.text}" - ) - raise e - return response - - -# monkey-patch requests.Response.successful -requests.Response.successful = successful - - class Client: """Python Client for Tamr API. diff --git a/tamr_unify_client/response.py b/tamr_unify_client/response.py new file mode 100644 index 00000000..9fbc85ae --- /dev/null +++ b/tamr_unify_client/response.py @@ -0,0 +1,31 @@ +import logging + +import requests + +logger = logging.getLogger(__name__) + + +def successful(response: requests.Response) -> requests.Response: + """Ensure response does not contain an HTTP error. + + Delegates to :func:`requests.Response.raise_for_status` + + Returns: + The response being checked. + + Raises: + requests.exceptions.HTTPError: If an HTTP error is encountered. + """ + try: + response.raise_for_status() + except requests.HTTPError as e: + r = e.response + logger.error( + f"Encountered HTTP error code {r.status_code}. Response body: {r.text}" + ) + raise e + return response + + +# monkey-patch requests.Response.successful +requests.Response.successful = successful # type: ignore From 842a975e50eef1031518a5d7b1091464e9fa6b0a Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 29 Jan 2020 11:49:50 -0500 Subject: [PATCH 229/632] Invoke as a Python-based task runner poetry run invoke [lint|format|test|docs] --- poetry.lock | 15 ++++++++++++++- pyproject.toml | 1 + tasks.py | 21 +++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 tasks.py diff --git a/poetry.lock b/poetry.lock index 15b320d5..d9ab01f5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -201,6 +201,14 @@ zipp = ">=0.5" docs = ["sphinx", "rst.linker"] testing = ["packaging", "importlib-resources"] +[[package]] +category = "dev" +description = "Pythonic task execution" +name = "invoke" +optional = false +python-versions = "*" +version = "1.4.0" + [[package]] category = "dev" description = "A small but fast and easy to use stand-alone template engine written in pure python." @@ -614,7 +622,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["pathlib2", "contextlib2", "unittest2"] [metadata] -content-hash = "53ec8ec3ea9709d54c9579358a0b68b4779d9f4779c26ee339713fb91154be86" +content-hash = "e7c9a0cacdc344601acc6a27909b8aa4cb4b1f0dd24cf01bf04466d150bfd997" python-versions = "^3.6" [metadata.files] @@ -698,6 +706,11 @@ importlib-metadata = [ {file = "importlib_metadata-1.3.0-py2.py3-none-any.whl", hash = "sha256:d95141fbfa7ef2ec65cfd945e2af7e5a6ddbd7c8d9a25e66ff3be8e3daf9f60f"}, {file = "importlib_metadata-1.3.0.tar.gz", hash = "sha256:073a852570f92da5f744a3472af1b61e28e9f78ccf0c9117658dc32b15de7b45"}, ] +invoke = [ + {file = "invoke-1.4.0-py2-none-any.whl", hash = "sha256:4668a4a594a47f2da2f0672ec2f7b1566f809cebf10bcd95ce2de9ecf39b95d1"}, + {file = "invoke-1.4.0-py3-none-any.whl", hash = "sha256:e04faba8ea7cdf6f5c912be42dcafd5c1074b7f2f306998992c4bfb40a9a690b"}, + {file = "invoke-1.4.0.tar.gz", hash = "sha256:ae7b4513638bde9afcda0825e9535599637a3f65bd819a27098356027bb17c8a"}, +] jinja2 = [ {file = "Jinja2-2.10.1-py2.py3-none-any.whl", hash = "sha256:14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b"}, {file = "Jinja2-2.10.1.tar.gz", hash = "sha256:065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013"}, diff --git a/pyproject.toml b/pyproject.toml index 275cbde7..2139bd42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ recommonmark = "^0.6.0" sphinx-autodoc-typehints = "^1.8" pandas = "^0.25.3" pytest = "^5.3.2" +invoke = "^1.4.0" [build-system] requires = ["poetry>=1.0"] diff --git a/tasks.py b/tasks.py new file mode 100644 index 00000000..f318c9dc --- /dev/null +++ b/tasks.py @@ -0,0 +1,21 @@ +from invoke import task + + +@task +def lint(c): + c.run("poetry run flake8 .", echo=True, pty=True) + + +@task +def format(c): + c.run("poetry run black --check .", echo=True, pty=True) + + +@task +def test(c): + c.run("poetry run pytest", echo=True, pty=True) + + +@task +def docs(c): + c.run("poetry run sphinx-build -b html docs docs/_build -W", echo=True, pty=True) From 05e0bd1472ca60eeb9a10233401dc1390a6cbb16 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 29 Jan 2020 11:51:23 -0500 Subject: [PATCH 230/632] CI running invoke --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebc3d262..50d3f22d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,9 +24,9 @@ jobs: - name: Install dependencies run: poetry install - name: Run flake8 - run: poetry run flake8 . + run: poetry run invoke lint - name: Run black - run: poetry run black --check . + run: poetry run invoke format Test: strategy: @@ -44,7 +44,7 @@ jobs: - name: Install dependencies run: poetry install - name: Run pytest - run: poetry run pytest + run: poetry run invoke test Docs: runs-on: ubuntu-latest @@ -59,4 +59,4 @@ jobs: - name: Install dependencies run: poetry install - name: Build docs - run: poetry run sphinx-build -b html docs docs/_build -W + run: poetry run invoke docs From 4a23e7b60ce400b4115d2f02e6ba76aa3ec2645d Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 29 Jan 2020 13:14:34 -0500 Subject: [PATCH 231/632] fixup: invoke --- tasks.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tasks.py b/tasks.py index f318c9dc..cb72f97a 100644 --- a/tasks.py +++ b/tasks.py @@ -7,8 +7,9 @@ def lint(c): @task -def format(c): - c.run("poetry run black --check .", echo=True, pty=True) +def format(c, fix=False): + check = "" if fix else "--check" + c.run(f"poetry run black {check} .", echo=True, pty=True) @task From 708f41003513245fa53a68c1ea2cb496abede107 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 29 Jan 2020 13:14:47 -0500 Subject: [PATCH 232/632] Fix flakely docs warning/error Colons : within backticks ` can cause issues in sphinx when they are not given an explicit role. Provide explicit :code: role --- tamr_unify_client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_unify_client/client.py b/tamr_unify_client/client.py index 739b88c7..54689cf6 100644 --- a/tamr_unify_client/client.py +++ b/tamr_unify_client/client.py @@ -64,7 +64,7 @@ def __init__( @property def origin(self) -> str: - """HTTP origin i.e. ``://[:]``. + """HTTP origin i.e. :code:`://[:]`. For additional information, see `MDN web docs `_ . """ From 147296bce635a5c69fe4b6ff6d335b06491fdafc Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 29 Jan 2020 13:16:49 -0500 Subject: [PATCH 233/632] Rename "Developer Interface" to "Reference" Reference landing page Reference split up into subdocs --- docs/developer-interface.rst | 283 ---------------------- docs/index.md | 4 +- docs/reference.md | 11 + docs/reference/attribute.rst | 37 +++ docs/reference/auth.rst | 4 + docs/reference/categorization.rst | 35 +++ docs/reference/client.rst | 5 + docs/reference/dataset.rst | 52 ++++ docs/reference/machine_learning_model.rst | 5 + docs/reference/mastering.rst | 58 +++++ docs/reference/operation.rst | 5 + docs/reference/project.rst | 69 ++++++ 12 files changed, 283 insertions(+), 285 deletions(-) delete mode 100644 docs/developer-interface.rst create mode 100644 docs/reference.md create mode 100644 docs/reference/attribute.rst create mode 100644 docs/reference/auth.rst create mode 100644 docs/reference/categorization.rst create mode 100644 docs/reference/client.rst create mode 100644 docs/reference/dataset.rst create mode 100644 docs/reference/machine_learning_model.rst create mode 100644 docs/reference/mastering.rst create mode 100644 docs/reference/operation.rst create mode 100644 docs/reference/project.rst diff --git a/docs/developer-interface.rst b/docs/developer-interface.rst deleted file mode 100644 index 54c7b697..00000000 --- a/docs/developer-interface.rst +++ /dev/null @@ -1,283 +0,0 @@ -Developer Interface -=================== -.. _authentication: - -Authentication --------------- - -.. autoclass:: tamr_unify_client.auth.UsernamePasswordAuth - -Client ------- - -.. autoclass:: tamr_unify_client.Client - :members: - -Attributes ----------- - -Attribute -^^^^^^^^^ - -.. autoclass:: tamr_unify_client.attribute.resource.Attribute - :members: - -Attribute Spec -^^^^^^^^^^^^^^ - -.. autoclass:: tamr_unify_client.attribute.resource.AttributeSpec - :members: - -Attribute Collection -^^^^^^^^^^^^^^^^^^^^ - -.. autoclass:: tamr_unify_client.attribute.collection.AttributeCollection - :members: - -Attribute Type -^^^^^^^^^^^^^^ - -.. autoclass:: tamr_unify_client.attribute.type.AttributeType - :members: - -Attribute Type Spec -^^^^^^^^^^^^^^^^^^^ - -.. autoclass:: tamr_unify_client.attribute.type.AttributeTypeSpec - :members: - -SubAttribute -^^^^^^^^^^^^ -.. autoclass:: tamr_unify_client.attribute.subattribute.SubAttribute - :members: - -Categorization --------------- - -Categorization Project -^^^^^^^^^^^^^^^^^^^^^^ - -.. autoclass:: tamr_unify_client.categorization.project.CategorizationProject - :members: - -Categories -^^^^^^^^^^ - -Category -"""""""" - -.. autoclass:: tamr_unify_client.categorization.category.resource.Category - :members: - -Category Spec -""""""""""""" - -.. autoclass:: tamr_unify_client.categorization.category.resource.CategorySpec - :members: - -Category Collection -""""""""""""""""""" - -.. autoclass:: tamr_unify_client.categorization.category.collection.CategoryCollection - :members: - -Taxonomy -^^^^^^^^ - -.. autoclass:: tamr_unify_client.categorization.taxonomy.Taxonomy - :members: - -Datasets --------- - -Dataset -^^^^^^^ - -.. autoclass:: tamr_unify_client.dataset.resource.Dataset - :members: - -Dataset Spec -^^^^^^^^^^^^ - -.. autoclass:: tamr_unify_client.dataset.resource.DatasetSpec - :members: - -Dataset Collection -^^^^^^^^^^^^^^^^^^ - -.. autoclass:: tamr_unify_client.dataset.collection.DatasetCollection - :members: -.. autoclass:: tamr_unify_client.dataset.collection.CreationError - :members: - -Dataset Profile -^^^^^^^^^^^^^^^ - -.. autoclass:: tamr_unify_client.dataset.profile.DatasetProfile - :members: - -Dataset Status -^^^^^^^^^^^^^^ - -.. autoclass:: tamr_unify_client.dataset.status.DatasetStatus - :members: - -Dataset URI -^^^^^^^^^^^ - -.. autoclass:: tamr_unify_client.dataset.uri.DatasetURI - :members: - -Dataset Usage -^^^^^^^^^^^^^ - -.. autoclass:: tamr_unify_client.dataset.usage.DatasetUsage - :members: - -Dataset Use -^^^^^^^^^^^ - -.. autoclass:: tamr_unify_client.dataset.use.DatasetUse - :members: - -Machine Learning Model ----------------------- - -.. autoclass:: tamr_unify_client.base_model.MachineLearningModel - :members: - -Mastering ---------- - -Binning Model -^^^^^^^^^^^^^ - -.. autoclass:: tamr_unify_client.mastering.binning_model.BinningModel - :members: - -Estimated Pair Counts -^^^^^^^^^^^^^^^^^^^^^ - -.. autoclass:: tamr_unify_client.mastering.estimated_pair_counts.EstimatedPairCounts - :members: - -Mastering Project -^^^^^^^^^^^^^^^^^ - -.. autoclass:: tamr_unify_client.mastering.project.MasteringProject - :members: - -Published Clusters -^^^^^^^^^^^^^^^^^^ - -Metric -"""""" -.. autoclass:: tamr_unify_client.mastering.published_cluster.metric.Metric - :members: - - -Published Cluster -""""""""""""""""" -.. autoclass:: tamr_unify_client.mastering.published_cluster.resource.PublishedCluster - :members: - -Published Cluster Configuration -""""""""""""""""""""""""""""""" - -.. autoclass:: tamr_unify_client.mastering.published_cluster.configuration.PublishedClustersConfiguration - :members: - -Published Cluster Version -""""""""""""""""""""""""" - -.. autoclass:: tamr_unify_client.mastering.published_cluster.version.PublishedClusterVersion - :members: - -Record Published Cluster -"""""""""""""""""""""""" - -.. autoclass:: tamr_unify_client.mastering.published_cluster.record.RecordPublishedCluster - :members: - -Record Published Cluster Version -"""""""""""""""""""""""""""""""" - -.. autoclass:: tamr_unify_client.mastering.published_cluster.record_version.RecordPublishedClusterVersion - :members: - -Operation ---------- - -.. autoclass:: tamr_unify_client.operation.Operation - :members: - - -Projects --------- - -Attribute Configurations -^^^^^^^^^^^^^^^^^^^^^^^^ - -Attribute Configuration -""""""""""""""""""""""" - -.. autoclass:: tamr_unify_client.project.attribute_configuration.resource.AttributeConfiguration - :members: - -Attribute Configuration Spec -"""""""""""""""""""""""""""" - -.. autoclass:: tamr_unify_client.project.attribute_configuration.resource.AttributeConfigurationSpec - :members: - -Attribute Configuration Collection -"""""""""""""""""""""""""""""""""" - -.. autoclass:: tamr_unify_client.project.attribute_configuration.collection.AttributeConfigurationCollection - :members: - - -Attribute Mappings -^^^^^^^^^^^^^^^^^^ - -Attribute Mapping -""""""""""""""""" - -.. autoclass:: tamr_unify_client.project.attribute_mapping.resource.AttributeMapping - :members: - -Attribute Mapping Spec -"""""""""""""""""""""" - -.. autoclass:: tamr_unify_client.project.attribute_mapping.resource.AttributeMappingSpec - :members: - -Attribute Mapping Collection -"""""""""""""""""""""""""""" - -.. autoclass:: tamr_unify_client.project.attribute_mapping.collection.AttributeMappingCollection - :members: - -Project -^^^^^^^ - -.. autoclass:: tamr_unify_client.project.resource.Project - :members: - -Project Spec -^^^^^^^^^^^^ - -.. autoclass:: tamr_unify_client.project.resource.ProjectSpec - :members: - -Project Collection -^^^^^^^^^^^^^^^^^^ - -.. autoclass:: tamr_unify_client.project.collection.ProjectCollection - :members: - -Project Step -^^^^^^^^^^^^ - -.. autoclass:: tamr_unify_client.project.step.ProjectStep - :members: diff --git a/docs/index.md b/docs/index.md index 596154a4..73f459a1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -41,6 +41,6 @@ assert op.succeeded() * [Contributor guide](contributor-guide) -## Developer Interface +## Reference - * [Developer interface](developer-interface) + * [Reference](reference) diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 00000000..11b4d913 --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,11 @@ +## Reference + + * [Attributes](reference/attribute) + * [Auth](reference/auth) + * [Categorization](reference/categorization) + * [Client](reference/client) + * [Datasets](reference/dataset) + * [Machine Learning Model](reference/machine_learning_model) + * [Mastering](reference/mastering) + * [Operations](reference/operation) + * [Projects](reference/project) diff --git a/docs/reference/attribute.rst b/docs/reference/attribute.rst new file mode 100644 index 00000000..863d2b0f --- /dev/null +++ b/docs/reference/attribute.rst @@ -0,0 +1,37 @@ +Attributes +========== + +Attribute +--------- + +.. autoclass:: tamr_unify_client.attribute.resource.Attribute + :members: + +Attribute Spec +-------------- + +.. autoclass:: tamr_unify_client.attribute.resource.AttributeSpec + :members: + +Attribute Collection +-------------------- + +.. autoclass:: tamr_unify_client.attribute.collection.AttributeCollection + :members: + +Attribute Type +-------------- + +.. autoclass:: tamr_unify_client.attribute.type.AttributeType + :members: + +Attribute Type Spec +------------------- + +.. autoclass:: tamr_unify_client.attribute.type.AttributeTypeSpec + :members: + +SubAttribute +------------ +.. autoclass:: tamr_unify_client.attribute.subattribute.SubAttribute + :members: diff --git a/docs/reference/auth.rst b/docs/reference/auth.rst new file mode 100644 index 00000000..7ada7014 --- /dev/null +++ b/docs/reference/auth.rst @@ -0,0 +1,4 @@ +Auth +==== + +.. autoclass:: tamr_unify_client.auth.UsernamePasswordAuth diff --git a/docs/reference/categorization.rst b/docs/reference/categorization.rst new file mode 100644 index 00000000..bd70abfd --- /dev/null +++ b/docs/reference/categorization.rst @@ -0,0 +1,35 @@ +Categorization +============== + +Categorization Project +---------------------- + +.. autoclass:: tamr_unify_client.categorization.project.CategorizationProject + :members: + +Categories +---------- + +Category +^^^^^^^^ + +.. autoclass:: tamr_unify_client.categorization.category.resource.Category + :members: + +Category Spec +^^^^^^^^^^^^^ + +.. autoclass:: tamr_unify_client.categorization.category.resource.CategorySpec + :members: + +Category Collection +^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: tamr_unify_client.categorization.category.collection.CategoryCollection + :members: + +Taxonomy +-------- + +.. autoclass:: tamr_unify_client.categorization.taxonomy.Taxonomy + :members: diff --git a/docs/reference/client.rst b/docs/reference/client.rst new file mode 100644 index 00000000..9fb01c3b --- /dev/null +++ b/docs/reference/client.rst @@ -0,0 +1,5 @@ +Client +====== + +.. autoclass:: tamr_unify_client.Client + :members: diff --git a/docs/reference/dataset.rst b/docs/reference/dataset.rst new file mode 100644 index 00000000..8c0f90e2 --- /dev/null +++ b/docs/reference/dataset.rst @@ -0,0 +1,52 @@ +Datasets +======== + +Dataset +------- + +.. autoclass:: tamr_unify_client.dataset.resource.Dataset + :members: + +Dataset Spec +------------ + +.. autoclass:: tamr_unify_client.dataset.resource.DatasetSpec + :members: + +Dataset Collection +------------------ + +.. autoclass:: tamr_unify_client.dataset.collection.DatasetCollection + :members: +.. autoclass:: tamr_unify_client.dataset.collection.CreationError + :members: + +Dataset Profile +--------------- + +.. autoclass:: tamr_unify_client.dataset.profile.DatasetProfile + :members: + +Dataset Status +-------------- + +.. autoclass:: tamr_unify_client.dataset.status.DatasetStatus + :members: + +Dataset URI +----------- + +.. autoclass:: tamr_unify_client.dataset.uri.DatasetURI + :members: + +Dataset Usage +------------- + +.. autoclass:: tamr_unify_client.dataset.usage.DatasetUsage + :members: + +Dataset Use +----------- + +.. autoclass:: tamr_unify_client.dataset.use.DatasetUse + :members: diff --git a/docs/reference/machine_learning_model.rst b/docs/reference/machine_learning_model.rst new file mode 100644 index 00000000..f42bd999 --- /dev/null +++ b/docs/reference/machine_learning_model.rst @@ -0,0 +1,5 @@ +Machine Learning Model +---------------------- + +.. autoclass:: tamr_unify_client.base_model.MachineLearningModel + :members: diff --git a/docs/reference/mastering.rst b/docs/reference/mastering.rst new file mode 100644 index 00000000..2c868263 --- /dev/null +++ b/docs/reference/mastering.rst @@ -0,0 +1,58 @@ +Mastering +========= + +Binning Model +------------- + +.. autoclass:: tamr_unify_client.mastering.binning_model.BinningModel + :members: + +Estimated Pair Counts +--------------------- + +.. autoclass:: tamr_unify_client.mastering.estimated_pair_counts.EstimatedPairCounts + :members: + +Mastering Project +----------------- + +.. autoclass:: tamr_unify_client.mastering.project.MasteringProject + :members: + +Published Clusters +------------------ + +Metric +^^^^^^ +.. autoclass:: tamr_unify_client.mastering.published_cluster.metric.Metric + :members: + + +Published Cluster +^^^^^^^^^^^^^^^^^ +.. autoclass:: tamr_unify_client.mastering.published_cluster.resource.PublishedCluster + :members: + +Published Cluster Configuration +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: tamr_unify_client.mastering.published_cluster.configuration.PublishedClustersConfiguration + :members: + +Published Cluster Version +^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: tamr_unify_client.mastering.published_cluster.version.PublishedClusterVersion + :members: + +Record Published Cluster +^^^^^^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: tamr_unify_client.mastering.published_cluster.record.RecordPublishedCluster + :members: + +Record Published Cluster Version +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: tamr_unify_client.mastering.published_cluster.record_version.RecordPublishedClusterVersion + :members: diff --git a/docs/reference/operation.rst b/docs/reference/operation.rst new file mode 100644 index 00000000..51849ace --- /dev/null +++ b/docs/reference/operation.rst @@ -0,0 +1,5 @@ +Operation +========= + +.. autoclass:: tamr_unify_client.operation.Operation + :members: diff --git a/docs/reference/project.rst b/docs/reference/project.rst new file mode 100644 index 00000000..f44531f7 --- /dev/null +++ b/docs/reference/project.rst @@ -0,0 +1,69 @@ +Projects +======== + +Attribute Configurations +------------------------ + +Attribute Configuration +^^^^^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: tamr_unify_client.project.attribute_configuration.resource.AttributeConfiguration + :members: + +Attribute Configuration Spec +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: tamr_unify_client.project.attribute_configuration.resource.AttributeConfigurationSpec + :members: + +Attribute Configuration Collection +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: tamr_unify_client.project.attribute_configuration.collection.AttributeConfigurationCollection + :members: + + +Attribute Mappings +------------------ + +Attribute Mapping +^^^^^^^^^^^^^^^^^ + +.. autoclass:: tamr_unify_client.project.attribute_mapping.resource.AttributeMapping + :members: + +Attribute Mapping Spec +^^^^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: tamr_unify_client.project.attribute_mapping.resource.AttributeMappingSpec + :members: + +Attribute Mapping Collection +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: tamr_unify_client.project.attribute_mapping.collection.AttributeMappingCollection + :members: + +Project +------- + +.. autoclass:: tamr_unify_client.project.resource.Project + :members: + +Project Spec +------------ + +.. autoclass:: tamr_unify_client.project.resource.ProjectSpec + :members: + +Project Collection +------------------ + +.. autoclass:: tamr_unify_client.project.collection.ProjectCollection + :members: + +Project Step +------------ + +.. autoclass:: tamr_unify_client.project.step.ProjectStep + :members: From e9902a6865cb17c42851276ce6396167518197ee Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 29 Jan 2020 13:18:02 -0500 Subject: [PATCH 234/632] Refresh contributor guide --- docs/contributor-guide.md | 10 ++ docs/contributor-guide.rst | 154 ------------------ docs/contributor-guide/bugs-and-features.md | 10 ++ docs/contributor-guide/config-text-editor.md | 5 + docs/contributor-guide/install.md | 32 ++++ docs/contributor-guide/migration.md | 5 + .../navigating-inheritance.md | 25 +++ docs/contributor-guide/pull-request.md | 44 +++++ .../resource:collectionRequest.png | Bin .../resource:collectionRoute.png | Bin docs/contributor-guide/run-and-build.md | 63 +++++++ docs/contributor-guide/toolchain.md | 29 ++++ 12 files changed, 223 insertions(+), 154 deletions(-) create mode 100644 docs/contributor-guide.md delete mode 100644 docs/contributor-guide.rst create mode 100644 docs/contributor-guide/bugs-and-features.md create mode 100644 docs/contributor-guide/config-text-editor.md create mode 100644 docs/contributor-guide/install.md create mode 100644 docs/contributor-guide/migration.md create mode 100644 docs/contributor-guide/navigating-inheritance.md create mode 100644 docs/contributor-guide/pull-request.md rename docs/{ => contributor-guide}/resource:collectionRequest.png (100%) rename docs/{ => contributor-guide}/resource:collectionRoute.png (100%) create mode 100644 docs/contributor-guide/run-and-build.md create mode 100644 docs/contributor-guide/toolchain.md diff --git a/docs/contributor-guide.md b/docs/contributor-guide.md new file mode 100644 index 00000000..23e0b7df --- /dev/null +++ b/docs/contributor-guide.md @@ -0,0 +1,10 @@ +# Contributor guide + + * [Bug Reports and Feature Requests](contributor-guide/bugs-and-features) + * [Code Migrations](contributor-guide/migration) + * [Configure your Text Editor](contributor-guide/config-text-editor) + * [Install](contributor-guide/install) + * [Navigating Inheritance](contributor-guide/navigating-inheritance) + * [Pull Requests](contributor-guide/pull-request) + * [Run and Build](contributor-guide/run-and-build) + * [Toolchain](contributor-guide/toolchain) diff --git a/docs/contributor-guide.rst b/docs/contributor-guide.rst deleted file mode 100644 index 0e16a059..00000000 --- a/docs/contributor-guide.rst +++ /dev/null @@ -1,154 +0,0 @@ -Contributor Guide -================= - -.. _bug-reports-feature-requests: - -🐛 Bug Reports / 🙋 Feature Requests ------------------------------------- - -Please leave bug reports and feature requests as `Github issues `_ . - ----- - -Be sure to check through existing issues (open and closed) to confirm that the -bug hasn’t been reported before. - -Duplicate bug reports are a huge drain on the time of other contributors, and -should be avoided as much as possible. - -↪️ Pull Requests ----------------- - -For larger, new features: - - `Open an RFC issue `_ . - Discuss the feature with project maintainers to be sure that your change fits with the project - vision and that you won't be wasting effort going in the wrong direction. - - Once you get the green light 🚦 from maintainers, you can proceed with the PR. - -Contributions / PRs should follow the -`Forking Workflow `_ : - - 1. Fork it: https://github.com/[your-github-username]/tamr-client/fork - 2. Create your feature branch:: - - git checkout -b my-new-feature - - 3. Commit your changes:: - - git commit -am 'Add some feature' - - 4. Push to the branch:: - - git push origin my-new-feature - - 5. Create a new Pull Request - ----- - -We optimize for PR readability, so please squash commits before and during the PR -review process if you think it will help reviewers and onlookers navigate your changes. - -Don't be afraid to ``push -f`` on your PRs when it helps our eyes read your code. - -Install -------- - -This project uses ``poetry`` as its package manager. For details on ``poetry``, -see the `official documentation `_ . - - 1. Install `pyenv `_:: - - curl https://pyenv.run | bash - - 2. Clone your fork and ``cd`` into the project:: - - git clone https://github.com//tamr-client - cd tamr-client - - 3. Use ``pyenv`` to install a compatible Python version (``3.6`` or newer; e.g. ``3.7.3``):: - - pyenv install 3.7.3 - - 4. Set that Python version to be your version for this project(e.g. ``3.7.3``):: - - pyenv local 3.7.3 - - 5. Check that your Python version matches the version specified in ``.python-version``:: - - cat .python-version - python --version - - 6. Install ``poetry`` as `described here `_:: - - curl -sSL https://raw.githubusercontent.com/sdispater/poetry/master/get-poetry.py | python - - 7. Install dependencies via ``poetry``:: - - poetry install - -Run tests ---------- - -To run all tests:: - - poetry run pytest . - -To run specific tests, see `these pytest docs `_ . - -Run style checks ----------------- - -To run linter:: - - poetry run flake8 . - -To run formatter:: - - poetry run black --check . - -Run the formatter without the `--check` flag to fix formatting in-place. - -Build docs ----------- - -To build the docs:: - - cd docs/ - poetry run make html - -After docs are build, view them by:: - - cd docs/ # unless you are there already - open -a 'Google Chrome' _build/html/index.html # open in your favorite browser - -Editor config -------------- - -`Atom `_ : - -- `python-black `_ -- `linter-flake8 `_ - -Overview of Resource and Collection interaction (from_json and from_data confusion) ------------------------------------------------------------------------------------ - -`yourResource` and `yourCollection` are files that inherit from `baseResource` and `baseCollection`. Examples of such files would be `resource.py` and `collection.py` in the `attribute_configuration` folder under `project`. - -.. image:: resource:collectionRoute.png -.. image:: resource:collectionRequest.png - -**Step 1 (red)**: `yourCollection`’s `by_relative_id` returns `super.by_relative_id`, which comes from `baseCollection` - -**Step 1a (black)**: within `by_relative_id`, variable `resource_json` is defined as `self.client.get.[etc]`. `Client`’s `.get` returns `self.request` - -**Step 1b (black)**: `client`’s `.request` makes a request to the provided URL (this is the method actually fetching the data) - -**Step 2 (orange)**: `baseCollection`’s `by_relative_id` returns `resource_class.from_json`, which is the `from_json` defined in `yourResource` - -**Step 3 (yellow)**: `yourResource`’s `from_json` returns `super.from_data`, which comes from `baseResource` - -**Step 4 (green)**: `baseResource`’s `from_data` returns `cls` , one of the parameters entered for `from_data`. -`cls` is a `yourResource`, because in `from_json` the return type is specified to be a `yourResource`. -When `cls` is returned, a `yourResource` that has been filled with the data retrieved in `client`’s `.request` is what comes back. diff --git a/docs/contributor-guide/bugs-and-features.md b/docs/contributor-guide/bugs-and-features.md new file mode 100644 index 00000000..bd89f653 --- /dev/null +++ b/docs/contributor-guide/bugs-and-features.md @@ -0,0 +1,10 @@ +# 🐛 Bug Reports & 🙋 Feature Requests + +Please leave bug reports and feature requests as [Github issues](https://github.com/Datatamer/tamr-client/issues/new/choose) . + +--- + +Be sure to check through existing issues (open and closed) to confirm that the bug hasn’t been reported before. + +Duplicate bug reports are a huge drain on the time of other contributors, and +should be avoided as much as possible. diff --git a/docs/contributor-guide/config-text-editor.md b/docs/contributor-guide/config-text-editor.md new file mode 100644 index 00000000..29ee4a4a --- /dev/null +++ b/docs/contributor-guide/config-text-editor.md @@ -0,0 +1,5 @@ +# Configure your Text Editor + +- [Atom](https://atom.io/) + - [python-black](https://atom.io/packages/python-black) + - [linter-flake8](https://atom.io/packages/linter-flake8) diff --git a/docs/contributor-guide/install.md b/docs/contributor-guide/install.md new file mode 100644 index 00000000..438bbd4d --- /dev/null +++ b/docs/contributor-guide/install.md @@ -0,0 +1,32 @@ +# Install + +This project uses `pyenv` and `poetry`. +If you do not have these installed, checkout the [toolchain guide](toolchain). + +--- + +1. Clone your fork and `cd` into the project: + + ```sh + git clone https://github.com//tamr-client + cd tamr-client + ``` + +2. Set a Python version for this project. Must be Python 3.6+ (e.g. `3.7.3`): + + ```sh + pyenv local 3.7.3 + ``` + +3. Check that your Python version matches the version specified in `.python-version`: + + ```sh + cat .python-version + python --version + ``` + +4. Install dependencies via `poetry`: + + ```sh + poetry install + ``` diff --git a/docs/contributor-guide/migration.md b/docs/contributor-guide/migration.md new file mode 100644 index 00000000..62ef1546 --- /dev/null +++ b/docs/contributor-guide/migration.md @@ -0,0 +1,5 @@ +# Code Migrations + +Some of the codebase is old and outdated. + +To know which patterns to follow and which to avoid, you can check out [ongoing code migrations](https://github.com/Datatamer/tamr-client/labels/%F0%9F%93%88%20Ongoing%20Migration) diff --git a/docs/contributor-guide/navigating-inheritance.md b/docs/contributor-guide/navigating-inheritance.md new file mode 100644 index 00000000..da05b0fd --- /dev/null +++ b/docs/contributor-guide/navigating-inheritance.md @@ -0,0 +1,25 @@ +# Navigating Inheritance + +Older parts of the codebase heavily use inheritance. +We are in the process of [migrating to `dataclasses`](https://github.com/Datatamer/tamr-client/issues/309) to simplify the codebase, but in the meantime you might want to know how the inheritance machinery we have works. + +--- + +`yourResource` and `yourCollection` are files that inherit from `baseResource` and `baseCollection`. Examples of such files would be `resource.py` and `collection.py` in the `attribute_configuration` folder under `project`. + +![collection route](resource:collectionRoute.png) +![collection request](resource:collectionRequest.png) + +**Step 1 (red)**: `yourCollection`’s `by_relative_id` returns `super.by_relative_id`, which comes from `baseCollection` + +**Step 1a (black)**: within `by_relative_id`, variable `resource_json` is defined as `self.client.get.[etc]`. `Client`’s `.get` returns `self.request` + +**Step 1b (black)**: `client`’s `.request` makes a request to the provided URL (this is the method actually fetching the data) + +**Step 2 (orange)**: `baseCollection`’s `by_relative_id` returns `resource_class.from_json`, which is the `from_json` defined in `yourResource` + +**Step 3 (yellow)**: `yourResource`’s `from_json` returns `super.from_data`, which comes from `baseResource` + +**Step 4 (green)**: `baseResource`’s `from_data` returns `cls` , one of the parameters entered for `from_data`. +`cls` is a `yourResource`, because in `from_json` the return type is specified to be a `yourResource`. +When `cls` is returned, a `yourResource` that has been filled with the data retrieved in `client`’s `.request` is what comes back. diff --git a/docs/contributor-guide/pull-request.md b/docs/contributor-guide/pull-request.md new file mode 100644 index 00000000..84b8a8ae --- /dev/null +++ b/docs/contributor-guide/pull-request.md @@ -0,0 +1,44 @@ +# ↪️ Pull Requests + +For larger, new features: + + [Open an RFC issue](https://github.com/Datatamer/tamr-client/issues/new/choose). + Discuss the feature with project maintainers to be sure that your change fits with the project vision and that you won't be wasting effort going in the wrong direction. + + Once you get the green light 🚦 from maintainers, you can proceed with the PR. + +--- + +Contributions / PRs should follow the +[Forking Workflow](https://www.atlassian.com/git/tutorials/comparing-workflows/forking-workflow): + + 1. Fork it: `https://github.com/[your-github-username]/tamr-client/fork` + 2. Create your feature branch: + + ```sh + git checkout -b my-new-feature + ``` + + 3. Commit your changes: + + ```sh + git commit -am 'Add some feature' + ``` + + 4. Push to the branch: + + ```sh + git push origin my-new-feature + ``` + + 5. Create a new Pull Request + +---- + +We optimize for PR readability, so please squash commits before and during the PR review process if you think it will help reviewers and onlookers navigate your changes. + +Don't be afraid to `push -f` on your PRs when it helps our eyes read your code. + +--- + +Remember to check for any [ongoing code migrations](migration) that may be relevant to your PR. diff --git a/docs/resource:collectionRequest.png b/docs/contributor-guide/resource:collectionRequest.png similarity index 100% rename from docs/resource:collectionRequest.png rename to docs/contributor-guide/resource:collectionRequest.png diff --git a/docs/resource:collectionRoute.png b/docs/contributor-guide/resource:collectionRoute.png similarity index 100% rename from docs/resource:collectionRoute.png rename to docs/contributor-guide/resource:collectionRoute.png diff --git a/docs/contributor-guide/run-and-build.md b/docs/contributor-guide/run-and-build.md new file mode 100644 index 00000000..d213bf21 --- /dev/null +++ b/docs/contributor-guide/run-and-build.md @@ -0,0 +1,63 @@ +# Run and Build + +This project uses [invoke](http://www.pyinvoke.org/) as its task runner. + +Since `invoke` will be running inside of a `poetry` environment, we recommend adding the following alias to your `.bashrc` / `.zshrc` to save you some keystrokes: + +```sh +alias pri='poetry run invoke' +``` + +## Tests + +To run all tests: + +```sh +pri test # with alias +poetry run invoke test # without alias +``` + +To run specific tests, see [these pytest docs](https://docs.pytest.org/en/latest/usage.html#specifying-tests-selecting-tests) and run `pytest` explicitly: + +```sh +poetry run pytest tests/unit/test_attribute.py +``` + +## Linting & Formatting + +To run linter: + +```sh +pri lint # with alias +poetry run invoke lint # without alias +``` + +To run formatter: + +```sh +pri format # with alias +poetry run invoke format # without alias +``` + +Run the formatter with the `--fix` flag to autofix formatting: + +```sh +pri format --fix # with alias +poetry run invoke format --fix # without alias +``` + +## Docs + +To build the docs: + +```sh +pri docs # with alias +poetry run invoke docs # without alias +``` + +After docs are build, view them by: + +```sh + open -a 'firefox' docs/_build/index.html # open in Firefox + open -a 'Google Chrome' docs/_build/index.html # open in Chrome +``` diff --git a/docs/contributor-guide/toolchain.md b/docs/contributor-guide/toolchain.md new file mode 100644 index 00000000..c4a17056 --- /dev/null +++ b/docs/contributor-guide/toolchain.md @@ -0,0 +1,29 @@ +# Toolchain + +This project uses `poetry` as its package manager. For details on `poetry`, +see the [official documentation](https://poetry.eustace.io/). + + 1. Install [pyenv](https://github.com/pyenv/pyenv#installation>): + + ```sh + curl https://pyenv.run | bash + ``` + + 2. Use `pyenv` to install a compatible Python version (`3.6` or newer; e.g. `3.7.3`): + + ```sh + pyenv install 3.7.3 + ``` + + 3. Set that Python version to be your version for this project(e.g. `3.7.3`): + + ```sh + pyenv shell 3.7.3 + python --version # check that version matches your specified version + ``` + + 4. Install `poetry` as [described here](https://poetry.eustace.io/docs/#installation): + + ```sh + curl -sSL https://raw.githubusercontent.com/sdispater/poetry/master/get-poetry.py | python + ``` From 83c834eb4301cbc33d2e19411f9ff0a1cfb585b4 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 29 Jan 2020 14:39:18 -0500 Subject: [PATCH 235/632] Add mypy as a dev dependency --- mypy.ini | 9 +++++ poetry.lock | 90 +++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 mypy.ini diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..49382092 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,9 @@ +[mypy] +mypy_path = ./stubs +check_untyped_defs = True +ignore_errors = False +namespace_packages = True +strict_optional = True +warn_unused_ignores = True +warn_redundant_casts = True +warn_unused_configs = True diff --git a/poetry.lock b/poetry.lock index d9ab01f5..56c29504 100644 --- a/poetry.lock +++ b/poetry.lock @@ -247,6 +247,30 @@ optional = false python-versions = ">=3.5" version = "8.0.2" +[[package]] +category = "dev" +description = "Optional static typing for Python" +name = "mypy" +optional = false +python-versions = ">=3.5" +version = "0.761" + +[package.dependencies] +mypy-extensions = ">=0.4.3,<0.5.0" +typed-ast = ">=1.4.0,<1.5.0" +typing-extensions = ">=3.7.4" + +[package.extras] +dmypy = ["psutil (>=4.0)"] + +[[package]] +category = "dev" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +name = "mypy-extensions" +optional = false +python-versions = "*" +version = "0.4.3" + [[package]] category = "dev" description = "NumPy is the fundamental package for array computing with Python." @@ -584,6 +608,22 @@ optional = false python-versions = "*" version = "0.10.0" +[[package]] +category = "dev" +description = "a fork of Python 2 and 3 ast modules with type comment support" +name = "typed-ast" +optional = false +python-versions = "*" +version = "1.4.1" + +[[package]] +category = "dev" +description = "Backported and Experimental Type Hints for Python 3.5+" +name = "typing-extensions" +optional = false +python-versions = "*" +version = "3.7.4.1" + [[package]] category = "main" description = "HTTP library with thread-safe connection pooling, file post, and more." @@ -622,7 +662,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["pathlib2", "contextlib2", "unittest2"] [metadata] -content-hash = "e7c9a0cacdc344601acc6a27909b8aa4cb4b1f0dd24cf01bf04466d150bfd997" +content-hash = "780d46feedafa3cebf4fcaf0b4cf73f60b05f82cbc40764e0cf64f2138e48e4b" python-versions = "^3.6" [metadata.files] @@ -753,6 +793,26 @@ more-itertools = [ {file = "more-itertools-8.0.2.tar.gz", hash = "sha256:b84b238cce0d9adad5ed87e745778d20a3f8487d0f0cb8b8a586816c7496458d"}, {file = "more_itertools-8.0.2-py3-none-any.whl", hash = "sha256:c833ef592a0324bcc6a60e48440da07645063c453880c9477ceb22490aec1564"}, ] +mypy = [ + {file = "mypy-0.761-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:7f672d02fffcbace4db2b05369142e0506cdcde20cea0e07c7c2171c4fd11dd6"}, + {file = "mypy-0.761-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:87c556fb85d709dacd4b4cb6167eecc5bbb4f0a9864b69136a0d4640fdc76a36"}, + {file = "mypy-0.761-cp35-cp35m-win_amd64.whl", hash = "sha256:c6d27bd20c3ba60d5b02f20bd28e20091d6286a699174dfad515636cb09b5a72"}, + {file = "mypy-0.761-cp36-cp36m-macosx_10_6_x86_64.whl", hash = "sha256:4b9365ade157794cef9685791032521233729cb00ce76b0ddc78749abea463d2"}, + {file = "mypy-0.761-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:634aef60b4ff0f650d3e59d4374626ca6153fcaff96ec075b215b568e6ee3cb0"}, + {file = "mypy-0.761-cp36-cp36m-win_amd64.whl", hash = "sha256:53ea810ae3f83f9c9b452582261ea859828a9ed666f2e1ca840300b69322c474"}, + {file = "mypy-0.761-cp37-cp37m-macosx_10_6_x86_64.whl", hash = "sha256:0a9a45157e532da06fe56adcfef8a74629566b607fa2c1ac0122d1ff995c748a"}, + {file = "mypy-0.761-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:7eadc91af8270455e0d73565b8964da1642fe226665dd5c9560067cd64d56749"}, + {file = "mypy-0.761-cp37-cp37m-win_amd64.whl", hash = "sha256:e2bb577d10d09a2d8822a042a23b8d62bc3b269667c9eb8e60a6edfa000211b1"}, + {file = "mypy-0.761-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c35cae79ceb20d47facfad51f952df16c2ae9f45db6cb38405a3da1cf8fc0a7"}, + {file = "mypy-0.761-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:f97a605d7c8bc2c6d1172c2f0d5a65b24142e11a58de689046e62c2d632ca8c1"}, + {file = "mypy-0.761-cp38-cp38-win_amd64.whl", hash = "sha256:a6bd44efee4dc8c3324c13785a9dc3519b3ee3a92cada42d2b57762b7053b49b"}, + {file = "mypy-0.761-py3-none-any.whl", hash = "sha256:7e396ce53cacd5596ff6d191b47ab0ea18f8e0ec04e15d69728d530e86d4c217"}, + {file = "mypy-0.761.tar.gz", hash = "sha256:85baab8d74ec601e86134afe2bcccd87820f79d2f8d5798c889507d1088287bf"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] numpy = [ {file = "numpy-1.17.4-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:ede47b98de79565fcd7f2decb475e2dcc85ee4097743e551fe26cfc7eb3ff143"}, {file = "numpy-1.17.4-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:43bb4b70585f1c2d153e45323a886839f98af8bfa810f7014b20be714c37c447"}, @@ -921,6 +981,34 @@ toml = [ {file = "toml-0.10.0-py2.py3-none-any.whl", hash = "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"}, {file = "toml-0.10.0.tar.gz", hash = "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c"}, ] +typed-ast = [ + {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, + {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, + {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"}, + {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"}, + {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, + {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, + {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, + {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, + {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, + {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, + {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, + {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, + {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, + {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, + {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, + {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, + {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, + {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, + {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, + {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, + {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, +] +typing-extensions = [ + {file = "typing_extensions-3.7.4.1-py2-none-any.whl", hash = "sha256:910f4656f54de5993ad9304959ce9bb903f90aadc7c67a0bef07e678014e892d"}, + {file = "typing_extensions-3.7.4.1-py3-none-any.whl", hash = "sha256:cf8b63fedea4d89bab840ecbb93e75578af28f76f66c35889bd7065f5af88575"}, + {file = "typing_extensions-3.7.4.1.tar.gz", hash = "sha256:091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2"}, +] urllib3 = [ {file = "urllib3-1.25.3-py2.py3-none-any.whl", hash = "sha256:b246607a25ac80bedac05c6f282e3cdaf3afb65420fd024ac94435cabe6e18d1"}, {file = "urllib3-1.25.3.tar.gz", hash = "sha256:dbe59173209418ae49d485b87d1681aefa36252ee85884c31346debd19463232"}, diff --git a/pyproject.toml b/pyproject.toml index 2139bd42..7bf9d20d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ sphinx-autodoc-typehints = "^1.8" pandas = "^0.25.3" pytest = "^5.3.2" invoke = "^1.4.0" +mypy = "^0.761" [build-system] requires = ["poetry>=1.0"] From d4f220c3d23bedf13f708d36f5250951dee10625 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 30 Jan 2020 08:45:51 -0500 Subject: [PATCH 236/632] lint tests --- .flake8 | 2 +- tests/mock_api/test_continuous_mastering.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.flake8 b/.flake8 index 60415bed..0932e069 100644 --- a/.flake8 +++ b/.flake8 @@ -10,4 +10,4 @@ exclude = build,.venv,*.egg-info # flake8-import-order plugin import-order-style = google -application-import-names = tamr_unify_client +application-import-names = tamr_unify_client, tests diff --git a/tests/mock_api/test_continuous_mastering.py b/tests/mock_api/test_continuous_mastering.py index 4e296af2..98244dc9 100644 --- a/tests/mock_api/test_continuous_mastering.py +++ b/tests/mock_api/test_continuous_mastering.py @@ -1,10 +1,10 @@ import os import responses -from tests.mock_api.utils import mock_api from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth +from tests.mock_api.utils import mock_api basedir = os.path.dirname(__file__) From e444fcb7b4ab187d4b235ef8e296cee697ce0960 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 27 Jan 2020 22:42:14 -0500 Subject: [PATCH 237/632] Attributes module tests --- tests/attributes/test_attribute.py | 95 +++++++++++++++++++++++++ tests/attributes/test_attribute_type.py | 45 ++++++++++++ tests/data/attribute.json | 49 +++++++++++++ tests/data/attributes.json | 69 ++++++++++++++++++ tests/data/dataset.json | 23 ++++++ tests/data/updated_attribute.json | 9 +++ tests/utils.py | 9 +++ 7 files changed, 299 insertions(+) create mode 100644 tests/attributes/test_attribute.py create mode 100644 tests/attributes/test_attribute_type.py create mode 100644 tests/data/attribute.json create mode 100644 tests/data/attributes.json create mode 100644 tests/data/dataset.json create mode 100644 tests/data/updated_attribute.json create mode 100644 tests/utils.py diff --git a/tests/attributes/test_attribute.py b/tests/attributes/test_attribute.py new file mode 100644 index 00000000..3b09b06a --- /dev/null +++ b/tests/attributes/test_attribute.py @@ -0,0 +1,95 @@ +from dataclasses import replace +import json +from pathlib import Path + +from requests import Session +import responses + +import tamr_unify_client as tc +from tamr_unify_client.auth import UsernamePasswordAuth +from tamr_unify_client.dataset.resource import Dataset +from tests.utils import data_dir, load_json + + +def test_from_json(): + attrs_json = load_json(data_dir / "attributes.json") + dataset_id = 1 + for attr_json in attrs_json: + attr_id = attr_json["name"] + url = tc.URL( + path=f"api/versioned/v1/datasets/{dataset_id}/attributes/{attr_id}" + ) + attr = tc.attribute._from_json(url, attr_json) + assert attr.name == attr_json["name"] + assert attr.description == attr_json["description"] + assert attr.is_nullable == attr_json["isNullable"] + + +def test_json(): + """original -> to_json -> from_json -> original""" + attrs_json = load_json(data_dir / "attributes.json") + dataset_id = 1 + for attr_json in attrs_json: + attr_id = attr_json["name"] + url = tc.URL(f"api/versioned/v1/datasets/{dataset_id}/attributes/{attr_id}") + attr = tc.attribute._from_json(url, attr_json) + assert attr == tc.attribute._from_json(url, tc.attribute.to_json(attr)) + + +@responses.activate +def test_create(): + attrs = tuple( + [ + tc.SubAttribute( + name=str(i), + is_nullable=True, + type=tc.attribute_type.Array(inner_type=tc.attribute_type.String()), + ) + for i in range(4) + ] + ) + + auth = UsernamePasswordAuth("username", "password") + tamr = tc.Client(auth) + dataset_json = load_json(data_dir / "dataset.json") + dataset_url = tc.URL(path="api/versioned/v1/datasets/1") + dataset = Dataset.from_json(tamr, dataset_json, api_path=dataset_url.path) + + url = tc.URL(path=dataset.url.path + "/attributes") + attr_url = replace(url, path=url.path + "/attr") + attr_json = load_json(data_dir / "attribute.json") + responses.add(responses.POST, str(url), json=attr_json) + attr = tc.attribute.create( + Session(), + dataset, + name="attr", + is_nullable=False, + type=tc.attribute_type.Record(attributes=attrs), + ) + + assert attr == tc.attribute._from_json(attr_url, attr_json) + + +@responses.activate +def test_update(): + attr_url = tc.URL(path="api/versioned/v1/datasets/1/attributes/RowNum") + attr_json = load_json(data_dir / "attributes.json")[0] + attr = tc.attribute._from_json(attr_url, attr_json) + + updated_attr_json = load_json(data_dir / "updated_attribute.json") + responses.add(responses.PUT, str(attr_url), json=updated_attr_json) + updated_attr = tc.attribute.update( + Session(), attr, description=updated_attr_json["description"] + ) + + assert updated_attr == replace(attr, description=updated_attr_json["description"]) + + +@responses.activate +def test_delete(): + attr_url = tc.URL(path="api/versioned/v1/datasets/1/attributes/RowNum") + attr_json = load_json(data_dir / "attributes.json")[0] + attr = tc.attribute._from_json(attr_url, attr_json) + + responses.add(responses.DELETE, str(attr_url), status=204) + tc.attribute.delete(Session(), attr) diff --git a/tests/attributes/test_attribute_type.py b/tests/attributes/test_attribute_type.py new file mode 100644 index 00000000..042440c5 --- /dev/null +++ b/tests/attributes/test_attribute_type.py @@ -0,0 +1,45 @@ +import json +from pathlib import Path + +import tamr_unify_client as tc +from tests.utils import data_dir, load_json + + +def test_from_json(): + geom_json = load_json(data_dir / "attributes.json")[1] + geom_type = tc.attribute_type.from_json(geom_json["type"]) + assert isinstance(geom_type, tc.attribute_type.Record) + + for i, subattr in enumerate(geom_type.attributes): + assert isinstance(subattr, tc.SubAttribute) + if i == 0: + assert subattr.name == "point" + assert subattr.type == tc.attribute_type.Array(tc.attribute_type.Double()) + assert subattr.is_nullable + assert subattr.description is None + elif i == 1: + assert subattr.name == "lineString" + assert subattr.type == tc.attribute_type.Array( + tc.attribute_type.Array(tc.attribute_type.Double()) + ) + assert subattr.is_nullable + assert subattr.description is None + elif i == 2: + assert subattr.name == "polygon" + assert subattr.type == tc.attribute_type.Array( + tc.attribute_type.Array( + tc.attribute_type.Array(tc.attribute_type.Double()) + ) + ) + assert subattr.is_nullable + assert subattr.description is None + + +def test_json(): + attrs_json = load_json(data_dir / "attributes.json") + for attr_json in attrs_json: + attr_type_json = attr_json["type"] + attr_type = tc.attribute_type.from_json(attr_type_json) + assert attr_type == tc.attribute_type.from_json( + tc.attribute_type.to_json(attr_type) + ) diff --git a/tests/data/attribute.json b/tests/data/attribute.json new file mode 100644 index 00000000..505e6dfb --- /dev/null +++ b/tests/data/attribute.json @@ -0,0 +1,49 @@ +{ + "name": "attr", + "isNullable": false, + "type": { + "baseType": "RECORD", + "attributes": [ + { + "name": "0", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + }, + { + "name": "1", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + }, + { + "name": "2", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + }, + { + "name": "3", + "isNullable": true, + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "STRING" + } + } + } + ] + } +} diff --git a/tests/data/attributes.json b/tests/data/attributes.json new file mode 100644 index 00000000..8401df16 --- /dev/null +++ b/tests/data/attributes.json @@ -0,0 +1,69 @@ +[ + { + "name": "RowNum", + "description": "Synthetic row number", + "type": { + "baseType": "STRING", + "attributes": [] + }, + "isNullable": false + }, + { + "name": "geom", + "description": "", + "type": { + "baseType": "RECORD", + "attributes": [ + { + "name": "point", + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "DOUBLE", + "attributes": [] + }, + "attributes": [] + }, + "isNullable": true + }, + { + "name": "lineString", + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "ARRAY", + "innerType": { + "baseType": "DOUBLE", + "attributes": [] + }, + "attributes": [] + }, + "attributes": [] + }, + "isNullable": true + }, + { + "name": "polygon", + "type": { + "baseType": "ARRAY", + "innerType": { + "baseType": "ARRAY", + "innerType": { + "baseType": "ARRAY", + "innerType": { + "baseType": "DOUBLE", + "attributes": [] + }, + "attributes": [] + }, + "attributes": [] + }, + "attributes": [] + }, + "isNullable": true + } + ] + }, + "isNullable": false + } +] \ No newline at end of file diff --git a/tests/data/dataset.json b/tests/data/dataset.json new file mode 100644 index 00000000..b9934ef0 --- /dev/null +++ b/tests/data/dataset.json @@ -0,0 +1,23 @@ +{ + "id": "unify://unified-data/v1/datasets/1", + "externalId": "number 1", + "name": "dataset 1 name", + "description": "dataset 1 description", + "version": "dataset 1 version", + "keyAttributeNames": [ + "tamr_id" + ], + "tags": [], + "created": { + "username": "admin", + "time": "2018-09-10T16:06:20.636Z", + "version": "dataset 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2018-09-10T16:06:20.851Z", + "version": "dataset 1 modified version" + }, + "relativeId": "datasets/1", + "upstreamDatasetIds": [] +} \ No newline at end of file diff --git a/tests/data/updated_attribute.json b/tests/data/updated_attribute.json new file mode 100644 index 00000000..6995bea7 --- /dev/null +++ b/tests/data/updated_attribute.json @@ -0,0 +1,9 @@ +{ + "name": "RowNum", + "description": "Synthetic row number updated", + "type": { + "baseType": "STRING", + "attributes": [] + }, + "isNullable": false +} \ No newline at end of file diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 00000000..ced32e8e --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,9 @@ +import json +from pathlib import Path + +data_dir = Path(__file__).parent / "data" + + +def load_json(path: Path): + with open(path) as f: + return json.load(f) From e2f36de544573d2851709301fc21ec114d0561e4 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 27 Jan 2020 22:51:12 -0500 Subject: [PATCH 238/632] Utility types + modules --- tamr_unify_client/JsonDict.py | 4 ++++ tamr_unify_client/url.py | 12 ++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 tamr_unify_client/JsonDict.py create mode 100644 tamr_unify_client/url.py diff --git a/tamr_unify_client/JsonDict.py b/tamr_unify_client/JsonDict.py new file mode 100644 index 00000000..73440d49 --- /dev/null +++ b/tamr_unify_client/JsonDict.py @@ -0,0 +1,4 @@ +# taken from https://github.com/python/typing/issues/182 +from typing import Any, Dict + +JsonDict = Dict[str, Any] diff --git a/tamr_unify_client/url.py b/tamr_unify_client/url.py new file mode 100644 index 00000000..6616d13c --- /dev/null +++ b/tamr_unify_client/url.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class URL: + path: str + protocol: str = "http" + host: str = "localhost" + port: int = 9100 + + def __str__(self): + return f"{self.protocol}://{self.host}:{self.port}/{self.path}" From 94bb8304e27dbfa85d1523b5406edc2437a795f7 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 28 Jan 2020 09:22:11 -0500 Subject: [PATCH 239/632] Dataset url property --- tamr_unify_client/dataset/resource.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index 3f3eb36c..38611167 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -9,6 +9,7 @@ from tamr_unify_client.dataset.uri import DatasetURI from tamr_unify_client.dataset.usage import DatasetUsage from tamr_unify_client.operation import Operation +from tamr_unify_client.url import URL class Dataset(BaseResource): @@ -58,6 +59,20 @@ def attributes(self): alias = self.api_path + "/attributes" return AttributeCollection(self.client, alias) + @property + def url(self) -> URL: + """URL for this dataset + + Note: URL will be the alias used to retrieve this dataset + (e.g. ``https://localhost:9100/api/versioned/v1/projects/1/unifiedDataset``) + which might not be the same as the canonical URL (e.g. ``https://localhost:9100/api/versioned/v1/datasets/3``) + + Returns: + URL used to retrieve this dataset + """ + c = self.client + return URL(protocol=c.protocol, host=c.host, port=c.port, path=self.api_path) + def _update_records(self, updates, **json_args): """Send a batch of record creations/updates/deletions to this dataset. You probably want to use :func:`~tamr_unify_client.dataset.resource.Dataset.upsert_records` From 2aba7275830ce96352b652e07156a276de964256 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 27 Jan 2020 22:51:34 -0500 Subject: [PATCH 240/632] Attributes module --- tamr_unify_client/attribute/subattribute.py | 2 +- tamr_unify_client/attributes/attribute.py | 180 ++++++++++++++++++ .../attributes/attribute_type.py | 160 ++++++++++++++++ tamr_unify_client/attributes/subattribute.py | 55 ++++++ tamr_unify_client/base_collection.py | 4 +- .../categorization/category/resource.py | 2 +- tamr_unify_client/dataset/profile.py | 14 +- tamr_unify_client/dataset/resource.py | 4 +- tamr_unify_client/dataset/status.py | 6 +- tamr_unify_client/dataset/usage.py | 2 +- .../mastering/estimated_pair_counts.py | 6 +- 11 files changed, 415 insertions(+), 20 deletions(-) create mode 100644 tamr_unify_client/attributes/attribute.py create mode 100644 tamr_unify_client/attributes/attribute_type.py create mode 100644 tamr_unify_client/attributes/subattribute.py diff --git a/tamr_unify_client/attribute/subattribute.py b/tamr_unify_client/attribute/subattribute.py index 2377c89f..ddfc7b8e 100644 --- a/tamr_unify_client/attribute/subattribute.py +++ b/tamr_unify_client/attribute/subattribute.py @@ -42,4 +42,4 @@ def from_json(data: SubAttributeJson) -> "SubAttribute": # TODO implement AttributeType.from_json and use that instead type = AttributeType(type_json) - return SubAttribute(**dc, type=type, _json=_json) + return SubAttribute(**dc, type=type, _json=_json) # type: ignore diff --git a/tamr_unify_client/attributes/attribute.py b/tamr_unify_client/attributes/attribute.py new file mode 100644 index 00000000..fb9ca4a7 --- /dev/null +++ b/tamr_unify_client/attributes/attribute.py @@ -0,0 +1,180 @@ +from copy import deepcopy +from dataclasses import dataclass, field, replace +from typing import Optional + +from requests import Session + +import tamr_unify_client as tc +from tamr_unify_client.dataset.resource import Dataset +from tamr_unify_client.JsonDict import JsonDict + + +@dataclass(frozen=True) +class Attribute: + """A Tamr Attribute. + + See https://docs.tamr.com/reference#attribute-types + + Args: + url + name + type + description + """ + + url: tc.URL + name: str + type: tc.attribute_type.AttributeType + is_nullable: bool + _json: JsonDict = field(compare=False, repr=False) + description: Optional[str] = None + + +def from_resource_id(session: Session, dataset: Dataset, id: str) -> Attribute: + """Get attribute by resource ID + + Fetches attribute from Tamr server + + Args: + dataset: Dataset containing this attribute + id: Attribute ID + + Raises: + requests.HTTPError: If an HTTP error is encountered. + """ + url = replace(dataset.url, path=dataset.url.path + f"/{id}") + return _from_url(session, url) + + +def _from_url(session: Session, url: tc.URL) -> Attribute: + """Get attribute by URL + + Fetches attribute from Tamr server + + Args: + url: Attribute URL + + Raises: + requests.HTTPError: If an HTTP error is encountered. + """ + r = session.get(str(url)) + data = tc.response.successful(r).json() + return _from_json(url, data) + + +def _from_json(url: tc.URL, data: JsonDict) -> Attribute: + """Make attribute from JSON data (deserialize) + + Args: + url: Attribute URL + data: Attribute JSON data from Tamr server + """ + cp = deepcopy(data) + return Attribute( + url, + name=cp["name"], + description=cp.get("description"), + is_nullable=cp["isNullable"], + type=tc.attribute_type.from_json(cp["type"]), + _json=cp, + ) + + +def to_json(attr: Attribute) -> JsonDict: + """Serialize attribute into JSON + + Args: + attr: Attribute to serialize + + Returns: + JSON data representing the attribute + """ + d = { + "name": attr.name, + "type": tc.attribute_type.to_json(attr.type), + "isNullable": attr.is_nullable, + } + if attr.description is not None: + d["description"] = attr.description + return d + + +def create( + session: Session, + dataset: Dataset, + *, + name: str, + type: tc.attribute_type.AttributeType, + is_nullable: bool, + description: Optional[str] = None, +) -> Attribute: + """Create an attribute + + Posts a creation request to the Tamr server + + Args: + dataset: Dataset that should contain the new attribute + name: Name for the new attribute + type: Attribute type for the new attribute + is_nullable: Determines if the new attribute can contain NULL values + description: Description of the new attribute + + Returns: + The newly created attribute + + Raises: + requests.HTTPError: If an HTTP error is encountered. + """ + attrs_url = replace(dataset.url, path=dataset.url.path + "/attributes") + + body = { + "name": name, + "type": tc.attribute_type.to_json(type), + "isNullable": is_nullable, + } + if description is not None: + body["description"] = description + + r = session.post(str(attrs_url), json=body) + data = tc.response.successful(r).json() + name = data["name"] + url = replace(attrs_url, path=attrs_url.path + f"/{name}") + return _from_json(url, data) + + +def update( + session: Session, attribute: Attribute, *, description: Optional[str] = None +) -> Attribute: + """Update an existing attribute + + PUTS an update request to the Tamr server + + Args: + attribute: Existing attribute to update + description: Updated description for the existing attribute + + Returns: + The newly updated attribute + + Raises: + requests.HTTPError: If an HTTP error is encountered. + """ + updates = {"description": description} + r = session.put(str(attribute.url), json=updates) + data = tc.response.successful(r).json() + return _from_json(attribute.url, data) + + +def delete(session: Session, attribute: Attribute): + """Deletes an existing attribute + + Sends a deletion request to the Tamr server + + Args: + attribute: Existing attribute to delete + + Raises: + requests.HTTPError: If an HTTP error is encountered. + """ + r = session.delete(str(attribute.url)) + tc.response.successful(r) diff --git a/tamr_unify_client/attributes/attribute_type.py b/tamr_unify_client/attributes/attribute_type.py new file mode 100644 index 00000000..9c39b59e --- /dev/null +++ b/tamr_unify_client/attributes/attribute_type.py @@ -0,0 +1,160 @@ +from dataclasses import dataclass +import logging +from typing import ClassVar, Tuple, Type, Union + +import tamr_unify_client as tc +from tamr_unify_client.JsonDict import JsonDict + +logger = logging.getLogger(__name__) + +# primitive types +################# + + +@dataclass(frozen=True) +class Boolean: + """See https://docs.tamr.com/reference#attribute-types""" + + _tag: ClassVar[str] = "BOOLEAN" + + +@dataclass(frozen=True) +class Double: + """See https://docs.tamr.com/reference#attribute-types""" + + _tag: ClassVar[str] = "DOUBLE" + + +@dataclass(frozen=True) +class Int: + """See https://docs.tamr.com/reference#attribute-types""" + + _tag: ClassVar[str] = "INT" + + +@dataclass(frozen=True) +class Long: + """See https://docs.tamr.com/reference#attribute-types""" + + _tag: ClassVar[str] = "LONG" + + +@dataclass(frozen=True) +class String: + """See https://docs.tamr.com/reference#attribute-types""" + + _tag: ClassVar[str] = "STRING" + + +PrimitiveType = Union[Boolean, Double, Int, Long, String] + +# complex types +############### + + +@dataclass(frozen=True) +class Array: + """See https://docs.tamr.com/reference#attribute-types""" + + # NOTE(pcattori) sphinx_autodoc_typehints cannot handle recursive reference + # docstring written manually + _tag: ClassVar[str] = "ARRAY" + inner_type: "AttributeType" + + +@dataclass(frozen=True) +class Map: + """See https://docs.tamr.com/reference#attribute-types""" + + # NOTE(pcattori): sphinx_autodoc_typehints cannot handle recursive reference + # docstring written manually + _tag: ClassVar[str] = "MAP" + inner_type: "AttributeType" + + +@dataclass(frozen=True) +class Record: + """See https://docs.tamr.com/reference#attribute-types""" + + # NOTE(pcattori) sphinx_autodoc_typehints cannot handle recursive reference + # docstring written manually + _tag: ClassVar[str] = "RECORD" + attributes: Tuple["tc.SubAttribute", ...] + + +ComplexType = Union[Array, Map, Record] + +# attribute type +################ + +AttributeType = Union[PrimitiveType, ComplexType] + + +def from_json(data: JsonDict) -> AttributeType: + """Make an attribute type from JSON data (deserialize) + + Args: + data: JSON data from Tamr server + """ + base_type = data.get("baseType") + if base_type is None: + logger.error(f"JSON data: {repr(data)}") + raise ValueError("Missing required field 'baseType'.") + if base_type == Boolean._tag: + return Boolean() + elif base_type == Double._tag: + return Double() + elif base_type == Int._tag: + return Int() + elif base_type == Long._tag: + return Long() + elif base_type == String._tag: + return String() + elif base_type == Array._tag: + inner_type = data.get("innerType") + if inner_type is None: + logger.error(f"JSON data: {repr(data)}") + raise ValueError("Missing required field 'innerType' for Array type.") + return Array(inner_type=from_json(inner_type)) + elif base_type == Map._tag: + inner_type = data.get("innerType") + if inner_type is None: + logger.error(f"JSON data: {repr(data)}") + raise ValueError("Missing required field 'innerType' for Map type.") + return Map(inner_type=from_json(inner_type)) + elif base_type == Record._tag: + attributes = data.get("attributes") + if attributes is None: + logger.error(f"JSON data: {repr(data)}") + raise ValueError("Missing required field 'attributes' for Record type.") + return Record( + attributes=tuple([tc.subattribute.from_json(attr) for attr in attributes]) + ) + else: + logger.error(f"JSON data: {repr(data)}") + raise ValueError(f"Unrecognized 'baseType': {base_type}") + + +def to_json(attr_type: AttributeType) -> JsonDict: + """Serialize attribute type to JSON + + Args: + attr_type: Attribute type to serialize + """ + if isinstance(attr_type, (Boolean, Double, Int, Long, String)): + return {"baseType": type(attr_type)._tag} + elif isinstance(attr_type, (Array, Map)): + return { + "baseType": type(attr_type)._tag, + "innerType": to_json(attr_type.inner_type), + } + elif isinstance(attr_type, Record): + + return { + "baseType": type(attr_type)._tag, + "attributes": [ + tc.subattribute.to_json(attr) for attr in attr_type.attributes + ], + } + else: + raise TypeError(attr_type) diff --git a/tamr_unify_client/attributes/subattribute.py b/tamr_unify_client/attributes/subattribute.py new file mode 100644 index 00000000..ec16c3b0 --- /dev/null +++ b/tamr_unify_client/attributes/subattribute.py @@ -0,0 +1,55 @@ +from copy import deepcopy +from dataclasses import dataclass, field +from typing import Any, Dict, Optional + +import tamr_unify_client as tc +from tamr_unify_client.JsonDict import JsonDict + + +@dataclass(frozen=True) +class SubAttribute: + """An attribute which is itself a property of another attribute. + + See https://docs.tamr.com/reference#attribute-types + + Args: + name: Name of sub-attribute + description: Description of sub-attribute + type: See https://docs.tamr.com/reference#attribute-types + is_nullable: If this sub-attribute can be null + """ + + name: str + type: tc.AttributeType + is_nullable: bool + description: Optional[str] = None + + +def from_json(data: JsonDict) -> SubAttribute: + """Make a SubAttribute from JSON data (deserialize) + + Args: + data: JSON data received from Tamr server. + """ + cp = deepcopy(data) + d = {} + d["name"] = cp["name"] + d["is_nullable"] = cp["isNullable"] + d["type"] = tc.attribute_type.from_json(cp["type"]) + return SubAttribute(**d) + + +def to_json(subattr: SubAttribute) -> JsonDict: + """Serialize subattribute into JSON + + Args: + subattr: SubAttribute to serialize + """ + d = { + "name": subattr.name, + "type": tc.attribute_type.to_json(subattr.type), + "isNullable": subattr.is_nullable, + } + if subattr.description is not None: + d["description"] = subattr.description + return d diff --git a/tamr_unify_client/base_collection.py b/tamr_unify_client/base_collection.py index fd45e02f..6326644f 100644 --- a/tamr_unify_client/base_collection.py +++ b/tamr_unify_client/base_collection.py @@ -30,7 +30,7 @@ def by_resource_id(self, canonical_path, resource_id): :rtype: The ``resource_class`` for this collection. See :func:`~tamr_unify_client.base_collection.BaseCollection.by_relative_id`. """ relative_id = canonical_path + "/" + resource_id - return self.by_relative_id(relative_id) + return self.by_relative_id(relative_id) # type: ignore @abstractmethod def by_relative_id(self, resource_class, relative_id): @@ -68,7 +68,7 @@ def stream(self, resource_class): yield resource_class.from_json(self.client, resource_json) def __iter__(self): - return self.stream() + return self.stream() # type: ignore @abstractmethod def by_external_id(self, resource_class, external_id): diff --git a/tamr_unify_client/categorization/category/resource.py b/tamr_unify_client/categorization/category/resource.py index 90978383..8f657ebd 100644 --- a/tamr_unify_client/categorization/category/resource.py +++ b/tamr_unify_client/categorization/category/resource.py @@ -23,7 +23,7 @@ def description(self): @property def path(self): """:type: list[str]""" - return self._data.get("path")[:] + return self._data.get("path")[:] # type: ignore def parent(self): """Gets the parent Category of this one, or None if it is a tier 1 category diff --git a/tamr_unify_client/dataset/profile.py b/tamr_unify_client/dataset/profile.py index 35630d4d..7eac7d88 100644 --- a/tamr_unify_client/dataset/profile.py +++ b/tamr_unify_client/dataset/profile.py @@ -15,7 +15,7 @@ def dataset_name(self) -> str: :type: str """ - return self._data.get("datasetName") + return self._data.get("datasetName") # type: ignore @property def relative_dataset_id(self) -> str: @@ -23,7 +23,7 @@ def relative_dataset_id(self) -> str: :type: str """ - return self._data.get("relativeDatasetId") + return self._data.get("relativeDatasetId") # type: ignore @property def is_up_to_date(self) -> bool: @@ -31,7 +31,7 @@ def is_up_to_date(self) -> bool: :type: bool """ - return self._data.get("isUpToDate") + return self._data.get("isUpToDate") # type: ignore @property def profiled_data_version(self) -> str: @@ -39,7 +39,7 @@ def profiled_data_version(self) -> str: :type: str """ - return self._data.get("profiledDataVersion") + return self._data.get("profiledDataVersion") # type: ignore @property def profiled_at(self) -> dict: @@ -47,7 +47,7 @@ def profiled_at(self) -> dict: :type: dict """ - return self._data.get("profiledAt") + return self._data.get("profiledAt") # type: ignore @property def simple_metrics(self) -> list: @@ -55,7 +55,7 @@ def simple_metrics(self) -> list: :type: list """ - return self._data.get("simpleMetrics") + return self._data.get("simpleMetrics") # type: ignore @property def attribute_profiles(self) -> list: @@ -63,7 +63,7 @@ def attribute_profiles(self) -> list: :type: list """ - return self._data.get("attributeProfiles") + return self._data.get("attributeProfiles") # type: ignore def refresh(self, **options): """Updates the dataset profile if needed. diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index 38611167..1a5a75ee 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -42,12 +42,12 @@ def version(self): @property def tags(self): """:type: list[str]""" - return self._data.get("tags")[:] + return self._data.get("tags")[:] # type: ignore @property def key_attribute_names(self): """:type: list[str]""" - return self._data.get("keyAttributeNames")[:] + return self._data.get("keyAttributeNames")[:] # type: ignore @property def attributes(self): diff --git a/tamr_unify_client/dataset/status.py b/tamr_unify_client/dataset/status.py index d554983e..d41dd318 100644 --- a/tamr_unify_client/dataset/status.py +++ b/tamr_unify_client/dataset/status.py @@ -14,7 +14,7 @@ def dataset_name(self) -> str: :type: str """ - return self._data.get("datasetName") + return self._data.get("datasetName") # type: ignore @property def relative_dataset_id(self) -> str: @@ -22,7 +22,7 @@ def relative_dataset_id(self) -> str: :type: str """ - return self._data.get("relativeDatasetId") + return self._data.get("relativeDatasetId") # type: ignore @property def is_streamable(self) -> bool: @@ -30,7 +30,7 @@ def is_streamable(self) -> bool: :type: bool """ - return self._data.get("isStreamable") + return self._data.get("isStreamable") # type: ignore def __repr__(self) -> str: return ( diff --git a/tamr_unify_client/dataset/usage.py b/tamr_unify_client/dataset/usage.py index c580f8ce..f836c567 100644 --- a/tamr_unify_client/dataset/usage.py +++ b/tamr_unify_client/dataset/usage.py @@ -27,7 +27,7 @@ def usage(self): def dependencies(self): """:type: list[:class:`~tamr_unify_client.dataset.use.DatasetUse`]""" deps = self._data.get("dependencies") - return [DatasetUse(self.client, dep) for dep in deps] + return [DatasetUse(self.client, dep) for dep in deps] # type: ignore def __repr__(self): return ( diff --git a/tamr_unify_client/mastering/estimated_pair_counts.py b/tamr_unify_client/mastering/estimated_pair_counts.py index 43c84293..d96c725a 100644 --- a/tamr_unify_client/mastering/estimated_pair_counts.py +++ b/tamr_unify_client/mastering/estimated_pair_counts.py @@ -15,7 +15,7 @@ def is_up_to_date(self) -> bool: :rtype: bool """ - return self._data.get("isUpToDate") + return self._data.get("isUpToDate") # type: ignore @property def total_estimate(self) -> dict: @@ -29,7 +29,7 @@ def total_estimate(self) -> dict: } :rtype: dict[str, str] """ - return self._data.get("totalEstimate") + return self._data.get("totalEstimate") # type: ignore @property def clause_estimates(self) -> dict: @@ -49,7 +49,7 @@ def clause_estimates(self) -> dict: } :rtype: dict[str, dict[str, str]] """ - return self._data.get("clauseEstimates") + return self._data.get("clauseEstimates") # type: ignore def refresh(self, **options): """Updates the estimated pair counts if needed. From 92ab5d365b795f3fcf97ab8ff7b3ddcdb2f8b78b Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 29 Jan 2020 14:52:51 -0500 Subject: [PATCH 241/632] stubs for responses library --- stubs/responses.pyi | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 stubs/responses.pyi diff --git a/stubs/responses.pyi b/stubs/responses.pyi new file mode 100644 index 00000000..c740ff6d --- /dev/null +++ b/stubs/responses.pyi @@ -0,0 +1,19 @@ +from typing import Any, Dict, Optional, TypeVar + +JsonDict = Dict[str, Any] + +DELETE: str +GET: str +POST: str +PUT: str + +def add( + method: Optional[str] = None, + url: Optional[str] = None, + status: Optional[int] = None, + json: Optional[JsonDict] = None, +): ... + +T = TypeVar("T") + +def activate(T) -> T: ... From 42bb66588ccd1fcb938d4c2a022d6734bd5b7043 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 29 Jan 2020 15:03:16 -0500 Subject: [PATCH 242/632] Add typechecking via invoke --- .github/workflows/ci.yml | 15 ++++++++++ docs/contributor-guide/run-and-build.md | 40 +++++++++++++++---------- tasks.py | 13 ++++++++ 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50d3f22d..ac403b8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,21 @@ jobs: - name: Run black run: poetry run invoke format + Typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Install Python + uses: actions/setup-python@v1.1.1 + with: + python-version: 3.6 + - name: Install Poetry + uses: dschep/install-poetry-action@v1.2 + - name: Install dependencies + run: poetry install + - name: Run mypy + run: poetry run invoke typecheck + Test: strategy: matrix: diff --git a/docs/contributor-guide/run-and-build.md b/docs/contributor-guide/run-and-build.md index d213bf21..48a99437 100644 --- a/docs/contributor-guide/run-and-build.md +++ b/docs/contributor-guide/run-and-build.md @@ -8,21 +8,6 @@ Since `invoke` will be running inside of a `poetry` environment, we recommend ad alias pri='poetry run invoke' ``` -## Tests - -To run all tests: - -```sh -pri test # with alias -poetry run invoke test # without alias -``` - -To run specific tests, see [these pytest docs](https://docs.pytest.org/en/latest/usage.html#specifying-tests-selecting-tests) and run `pytest` explicitly: - -```sh -poetry run pytest tests/unit/test_attribute.py -``` - ## Linting & Formatting To run linter: @@ -46,6 +31,31 @@ pri format --fix # with alias poetry run invoke format --fix # without alias ``` +## Typechecks + +To run typechecks: + +```sh +pri typecheck # with alias +poetry run invoke typecheck # without alias +``` + +## Tests + +To run all tests: + +```sh +pri test # with alias +poetry run invoke test # without alias +``` + +To run specific tests, see [these pytest docs](https://docs.pytest.org/en/latest/usage.html#specifying-tests-selecting-tests) and run `pytest` explicitly: + +```sh +poetry run pytest tests/unit/test_attribute.py +``` + + ## Docs To build the docs: diff --git a/tasks.py b/tasks.py index cb72f97a..ff9d2c52 100644 --- a/tasks.py +++ b/tasks.py @@ -1,3 +1,5 @@ +from pathlib import Path + from invoke import task @@ -12,6 +14,17 @@ def format(c, fix=False): c.run(f"poetry run black {check} .", echo=True, pty=True) +@task +def typecheck(c, warn=True): + repo = Path(".") + tc = repo / "tamr_unify_client" + tests = repo / "tests" + pkgs = [tc / "attributes", tests / "attributes", tc / "datasets"] + for pkg in pkgs: + pyfiles = " ".join(str(pyfile) for pyfile in pkg.glob("**/*.py")) + c.run(f"poetry run mypy {pyfiles}", echo=True, pty=True, warn=warn) + + @task def test(c): c.run("poetry run pytest", echo=True, pty=True) From bde5d12d05461ff27c215edc1861cc75fb96971c Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 29 Jan 2020 22:54:44 -0500 Subject: [PATCH 243/632] Dataset module tests --- tests/datasets/test_dataset.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 tests/datasets/test_dataset.py diff --git a/tests/datasets/test_dataset.py b/tests/datasets/test_dataset.py new file mode 100644 index 00000000..328d0938 --- /dev/null +++ b/tests/datasets/test_dataset.py @@ -0,0 +1,32 @@ +from dataclasses import replace + +from requests import Session +import responses + +import tamr_unify_client as tc +from tamr_unify_client.auth import UsernamePasswordAuth +from tamr_unify_client.dataset.resource import Dataset +from tests.utils import data_dir, load_json + + +@responses.activate +def test__attributes(): + auth = UsernamePasswordAuth("username", "password") + tamr = tc.Client(auth) + dataset_json = load_json(data_dir / "dataset.json") + dataset_url = tc.URL(path="api/versioned/v1/datasets/1") + dataset = Dataset.from_json(tamr, dataset_json, api_path=dataset_url.path) + + attrs_url = replace(dataset.url, path=dataset.url.path + "/attributes") + attrs_json = load_json(data_dir / "attributes.json") + responses.add(responses.GET, str(attrs_url), json=attrs_json, status=204) + + attrs = tc.dataset._attributes(Session(), dataset) + + row_num = attrs[0] + assert row_num.name == "RowNum" + assert isinstance(row_num.type, tc.attribute_type.String) + + geom = attrs[1] + assert geom.name == "geom" + assert isinstance(geom.type, tc.attribute_type.Record) From 669a2129c0333a837154a54f2d04b58afe423f43 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 29 Jan 2020 22:55:22 -0500 Subject: [PATCH 244/632] Dataset module --- tamr_unify_client/datasets/dataset.py | 32 +++++++++++++++++++++++++++ tasks.py | 7 +++++- 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 tamr_unify_client/datasets/dataset.py diff --git a/tamr_unify_client/datasets/dataset.py b/tamr_unify_client/datasets/dataset.py new file mode 100644 index 00000000..571d9e9d --- /dev/null +++ b/tamr_unify_client/datasets/dataset.py @@ -0,0 +1,32 @@ +from dataclasses import replace +from typing import Tuple + +from requests import Session + +import tamr_unify_client as tc +from tamr_unify_client.dataset.resource import Dataset + + +def _attributes(session: Session, dataset: Dataset) -> Tuple[tc.Attribute, ...]: + """Get attributes for this dataset + + Args: + dataset: Dataset containing the desired attributes + + Returns: + The attributes for the specified dataset + + Raises: + requests.HTTPError: If an HTTP error is encountered. + """ + attrs_url = replace(dataset.url, path=dataset.url.path + "/attributes") + r = session.get(str(attrs_url)) + attrs_json = tc.response.successful(r).json() + + attrs = [] + for attr_json in attrs_json: + id = attr_json["name"] + attr_url = replace(attrs_url, path=attrs_url.path + f"/{id}") + attr = tc.attribute._from_json(attr_url, attr_json) + attrs.append(attr) + return tuple(attrs) diff --git a/tasks.py b/tasks.py index ff9d2c52..26bbaca9 100644 --- a/tasks.py +++ b/tasks.py @@ -19,7 +19,12 @@ def typecheck(c, warn=True): repo = Path(".") tc = repo / "tamr_unify_client" tests = repo / "tests" - pkgs = [tc / "attributes", tests / "attributes", tc / "datasets"] + pkgs = [ + tc / "attributes", + tests / "attributes", + tc / "datasets", + tests / "datasets", + ] for pkg in pkgs: pyfiles = " ".join(str(pyfile) for pyfile in pkg.glob("**/*.py")) c.run(f"poetry run mypy {pyfiles}", echo=True, pty=True, warn=warn) From 28ce4ec679b1d7b6036507be0226290fa6cef1cd Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 29 Jan 2020 22:55:35 -0500 Subject: [PATCH 245/632] Import shortcuts --- tamr_unify_client/__init__.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tamr_unify_client/__init__.py b/tamr_unify_client/__init__.py index b84da087..121f7bc9 100644 --- a/tamr_unify_client/__init__.py +++ b/tamr_unify_client/__init__.py @@ -1,6 +1,24 @@ +# flake8: noqa + import logging from tamr_unify_client.client import Client +# utilities +from tamr_unify_client.url import URL +import tamr_unify_client.response as response +import tamr_unify_client.url as url + +# attributes +from tamr_unify_client.attributes.attribute_type import AttributeType +import tamr_unify_client.attributes.attribute_type as attribute_type +from tamr_unify_client.attributes.subattribute import SubAttribute +import tamr_unify_client.attributes.subattribute as subattribute +from tamr_unify_client.attributes.attribute import Attribute +import tamr_unify_client.attributes.attribute as attribute + +# datasets +import tamr_unify_client.datasets.dataset as dataset + # https://docs.python-guide.org/writing/logging/#logging-in-a-library logging.getLogger(__name__).addHandler(logging.NullHandler()) From ba963453e88539ebc3847ad90106087a26cd4165 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 29 Jan 2020 22:55:57 -0500 Subject: [PATCH 246/632] Reference docs for beta features --- docs/reference.md | 11 +++++- docs/reference/beta/attributes.rst | 59 ++++++++++++++++++++++++++++++ docs/reference/beta/datasets.rst | 7 ++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 docs/reference/beta/attributes.rst create mode 100644 docs/reference/beta/datasets.rst diff --git a/docs/reference.md b/docs/reference.md index 11b4d913..fb1b6145 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1,4 +1,4 @@ -## Reference +# Reference * [Attributes](reference/attribute) * [Auth](reference/auth) @@ -9,3 +9,12 @@ * [Mastering](reference/mastering) * [Operations](reference/operation) * [Projects](reference/project) + +## Beta + + Features in this section are in beta and should not be relied on for production workflows. + + **WARN**: Do not expect to receive official support for features below. + + * [Attributes](reference/beta/attributes) + * [Datasets](reference/beta/datasets) diff --git a/docs/reference/beta/attributes.rst b/docs/reference/beta/attributes.rst new file mode 100644 index 00000000..dad65458 --- /dev/null +++ b/docs/reference/beta/attributes.rst @@ -0,0 +1,59 @@ +Attributes +========== + +Attribute +--------- + +.. autoclass:: tamr_unify_client.Attribute + +.. TODO(pcattori): note about import path +.. autofunction:: tamr_unify_client.attributes.attribute.from_resource_id +.. autofunction:: tamr_unify_client.attributes.attribute.to_json +.. autofunction:: tamr_unify_client.attributes.attribute.create +.. autofunction:: tamr_unify_client.attributes.attribute.update +.. autofunction:: tamr_unify_client.attributes.attribute.delete + +AttributeType +------------- + +.. autoclass:: tamr_unify_client.attribute_type.Boolean +.. autoclass:: tamr_unify_client.attribute_type.Double +.. autoclass:: tamr_unify_client.attribute_type.Int +.. autoclass:: tamr_unify_client.attribute_type.Long +.. autoclass:: tamr_unify_client.attribute_type.String + +.. NOTE(pcattori): Manually write docs for complex attribute types + Complex types recursively reference other attribute types or subattributes + sphinx_autodoc_typehints cannot properly parse types recursively + +.. class:: tamr_unify_client.attribute_type.Array(inner_type) + + See https://docs.tamr.com/reference#attribute-types + + :param inner_type: + :type inner_type: :class:`~tamr_unify_client.AttributeType` + +.. class:: tamr_unify_client.attribute_type.Map(inner_type) + + See https://docs.tamr.com/reference#attribute-types + + :param inner_type: + :type inner_type: :class:`~tamr_unify_client.AttributeType` + +.. class:: tamr_unify_client.attribute_type.Record(attributes) + + See https://docs.tamr.com/reference#attribute-types + + :param attributes: + :type attributes: :class:`~typing.Tuple` [:class:`~tamr_unify_client.SubAttribute`] + +.. autofunction:: tamr_unify_client.attribute_type.from_json +.. autofunction:: tamr_unify_client.attribute_type.to_json + +SubAttribute +------------ + +.. autoclass:: tamr_unify_client.SubAttribute + +.. autofunction:: tamr_unify_client.subattribute.from_json +.. autofunction:: tamr_unify_client.subattribute.to_json diff --git a/docs/reference/beta/datasets.rst b/docs/reference/beta/datasets.rst new file mode 100644 index 00000000..c7dda181 --- /dev/null +++ b/docs/reference/beta/datasets.rst @@ -0,0 +1,7 @@ +Datasets +======== + +Dataset +------- + +.. autofunction:: tamr_unify_client.datasets.dataset._attributes From 232647043ab62eea228534ecb8339d17b5be779c Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 30 Jan 2020 16:14:33 -0500 Subject: [PATCH 247/632] Add changelog entries for: - attributes package - datasets package - url module --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c14f8283..312767fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,25 @@ ## 0.11.0-dev + **NEW FEATURES** + - BETA: New attributes package! + - `tc.attribute` module + - `tc.Attribute` class + - functions: `from_resource_id`, `to_json`, `create`, `update` + - `tc.attribute_type` module + - `tc.AttributeType` for type annotations + - Primitive Types: `Boolean`, `Double`, `Int`, `Long`, `String` + - Complex Types: `Array`, `Map`, `Record` + - functions: `from_json`, `to_json` + - `tc.subattribute` module + - `tc.SubAttribute` class + - functions: `from_json`, `to_json` + - BETA: New datasets package! + - `tc.dataset` module + - functions: `_attributes` + - New `tc.url` module! + - `tc.URL` class + + **BUG FIXES** + - Links from our docs to the `requests` docs were outdated. Links have been updated to point to the new `requests` docs URL. ## 0.10.0 **BREAKING CHANGES** From 308b1a80ee1e01113a2321693bb7dd8a126a5b98 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 30 Jan 2020 16:19:19 -0500 Subject: [PATCH 248/632] fix(docs): Prose changes --- docs/contributor-guide/bugs-and-features.md | 9 +++---- docs/contributor-guide/install.md | 28 ++++++++++----------- docs/reference.md | 7 +++--- 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/docs/contributor-guide/bugs-and-features.md b/docs/contributor-guide/bugs-and-features.md index bd89f653..e011b47e 100644 --- a/docs/contributor-guide/bugs-and-features.md +++ b/docs/contributor-guide/bugs-and-features.md @@ -1,10 +1,9 @@ -# 🐛 Bug Reports & 🙋 Feature Requests +# Submitting Bug Reports and Feature Requests -Please leave bug reports and feature requests as [Github issues](https://github.com/Datatamer/tamr-client/issues/new/choose) . +Submit bug reports and feature requests as [Github issues](https://github.com/Datatamer/tamr-client/issues/new/choose) . --- -Be sure to check through existing issues (open and closed) to confirm that the bug hasn’t been reported before. +Check through existing issues (open and closed) to confirm that the bug hasn’t been reported before. -Duplicate bug reports are a huge drain on the time of other contributors, and -should be avoided as much as possible. +If the bug/feature has been submitted already, leave a like 👍 on the Github Issue. diff --git a/docs/contributor-guide/install.md b/docs/contributor-guide/install.md index 438bbd4d..8107c9ed 100644 --- a/docs/contributor-guide/install.md +++ b/docs/contributor-guide/install.md @@ -7,26 +7,26 @@ If you do not have these installed, checkout the [toolchain guide](toolchain). 1. Clone your fork and `cd` into the project: - ```sh - git clone https://github.com//tamr-client - cd tamr-client - ``` + ```sh + git clone https://github.com//tamr-client + cd tamr-client + ``` 2. Set a Python version for this project. Must be Python 3.6+ (e.g. `3.7.3`): - ```sh - pyenv local 3.7.3 - ``` + ```sh + pyenv local 3.7.3 + ``` 3. Check that your Python version matches the version specified in `.python-version`: - ```sh - cat .python-version - python --version - ``` + ```sh + cat .python-version + python --version + ``` 4. Install dependencies via `poetry`: - ```sh - poetry install - ``` + ```sh + poetry install + ``` diff --git a/docs/reference.md b/docs/reference.md index fb1b6145..a4c18cb5 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -10,11 +10,10 @@ * [Operations](reference/operation) * [Projects](reference/project) -## Beta +## BETA - Features in this section are in beta and should not be relied on for production workflows. - - **WARN**: Do not expect to receive official support for features below. + **WARNING**: Do not rely on BETA features in production workflows. + Tamr will not offer support for BETA features. * [Attributes](reference/beta/attributes) * [Datasets](reference/beta/datasets) From 499530ee676a64ba22521bedb8c9743c6107390e Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 6 Feb 2020 15:34:08 -0500 Subject: [PATCH 249/632] Changes from working group --- .flake8 | 4 +- CHANGELOG.md | 13 +- docs/conf.py | 1 - docs/reference/beta/attributes.rst | 80 ++++++---- docs/reference/beta/datasets.rst | 2 +- poetry.lock | 7 +- stubs/pytest.pyi | 3 + tamr_client/__init__.py | 33 +++++ .../attributes/attribute.py | 112 ++++++++++++-- .../attributes/attribute_type.py | 40 ++--- .../attributes/subattribute.py | 10 +- tamr_client/attributes/type_alias.py | 23 +++ tamr_client/auth.py | 40 +++++ .../datasets/dataset.py | 13 +- .../JsonDict.py => tamr_client/json_dict.py | 0 tamr_client/response.py | 27 ++++ tamr_client/session.py | 12 ++ {tamr_unify_client => tamr_client}/url.py | 3 +- tamr_unify_client/__init__.py | 19 --- tamr_unify_client/attribute/subattribute.py | 2 +- tamr_unify_client/auth/__init__.py | 2 + tamr_unify_client/base_collection.py | 4 +- .../categorization/category/resource.py | 2 +- tamr_unify_client/client.py | 4 +- tamr_unify_client/dataset/profile.py | 14 +- tamr_unify_client/dataset/resource.py | 19 +-- tamr_unify_client/dataset/status.py | 6 +- tamr_unify_client/dataset/usage.py | 2 +- .../mastering/estimated_pair_counts.py | 6 +- tamr_unify_client/response.py | 4 +- tasks.py | 2 +- tests/attributes/test_attribute.py | 139 +++++++++++++----- tests/attributes/test_attribute_type.py | 11 +- tests/datasets/test_dataset.py | 20 +-- tests/unit/test_dataset_by_external_id.py | 2 - tests/unit/test_dataset_status.py | 2 - tests/utils.py | 19 ++- 37 files changed, 498 insertions(+), 204 deletions(-) create mode 100644 stubs/pytest.pyi create mode 100644 tamr_client/__init__.py rename {tamr_unify_client => tamr_client}/attributes/attribute.py (54%) rename {tamr_unify_client => tamr_client}/attributes/attribute_type.py (86%) rename {tamr_unify_client => tamr_client}/attributes/subattribute.py (87%) create mode 100644 tamr_client/attributes/type_alias.py create mode 100644 tamr_client/auth.py rename {tamr_unify_client => tamr_client}/datasets/dataset.py (74%) rename tamr_unify_client/JsonDict.py => tamr_client/json_dict.py (100%) create mode 100644 tamr_client/response.py create mode 100644 tamr_client/session.py rename {tamr_unify_client => tamr_client}/url.py (79%) diff --git a/.flake8 b/.flake8 index 0932e069..69541160 100644 --- a/.flake8 +++ b/.flake8 @@ -2,7 +2,7 @@ # https://ljvmiranda921.github.io/notebook/2018/06/21/precommits-using-black-and-flake8/ [flake8] -ignore = E203, E266, E501, W503, F403, F401 +ignore = E203, E266, E501, W503, F403 max-line-length = 88 max-complexity = 18 select = B,C,E,F,I,W,T4,B9 @@ -10,4 +10,4 @@ exclude = build,.venv,*.egg-info # flake8-import-order plugin import-order-style = google -application-import-names = tamr_unify_client, tests +application-import-names = tamr_client, tamr_unify_client, tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 312767fd..b232b4c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,12 @@ - BETA: New attributes package! - `tc.attribute` module - `tc.Attribute` class - - functions: `from_resource_id`, `to_json`, `create`, `update` + - functions: `from_resource_id`, `to_json`, `create`, `update`, `delete` - `tc.attribute_type` module - `tc.AttributeType` for type annotations - - Primitive Types: `Boolean`, `Double`, `Int`, `Long`, `String` + - Primitive Types: `BOOLEAN`, `DOUBLE`, `INT`, `LONG`, `STRING` - Complex Types: `Array`, `Map`, `Record` + - Type aliases: `DEFAULT`, `GEOSPATIAL` - functions: `from_json`, `to_json` - `tc.subattribute` module - `tc.SubAttribute` class @@ -15,8 +16,12 @@ - BETA: New datasets package! - `tc.dataset` module - functions: `_attributes` - - New `tc.url` module! - - `tc.URL` class + - BETA: New supporting modules! + - `tc.auth` module + - `tc.UsernamePasswordAuth` class + - `tc.session` function + - `tc.url` module + - `tc.URL` class **BUG FIXES** - Links from our docs to the `requests` docs were outdated. Links have been updated to point to the new `requests` docs URL. diff --git a/docs/conf.py b/docs/conf.py index e7848ccf..a5dc744b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,7 +15,6 @@ import os import sys -import recommonmark from recommonmark.transform import AutoStructify import toml diff --git a/docs/reference/beta/attributes.rst b/docs/reference/beta/attributes.rst index dad65458..f0c68e0c 100644 --- a/docs/reference/beta/attributes.rst +++ b/docs/reference/beta/attributes.rst @@ -4,56 +4,78 @@ Attributes Attribute --------- -.. autoclass:: tamr_unify_client.Attribute +.. autoclass:: tamr_client.Attribute -.. TODO(pcattori): note about import path -.. autofunction:: tamr_unify_client.attributes.attribute.from_resource_id -.. autofunction:: tamr_unify_client.attributes.attribute.to_json -.. autofunction:: tamr_unify_client.attributes.attribute.create -.. autofunction:: tamr_unify_client.attributes.attribute.update -.. autofunction:: tamr_unify_client.attributes.attribute.delete +.. autofunction:: tamr_client.attribute.from_resource_id +.. autofunction:: tamr_client.attribute.to_json +.. autofunction:: tamr_client.attribute.create +.. autofunction:: tamr_client.attribute.update +.. autofunction:: tamr_client.attribute.delete + +Exceptions +^^^^^^^^^^ + +.. autoclass:: tamr_client.ReservedAttributeName + :no-inherited-members: + +.. autoclass:: tamr_client.AttributeExists + :no-inherited-members: + +.. autoclass:: tamr_client.AttributeNotFound + :no-inherited-members: AttributeType ------------- -.. autoclass:: tamr_unify_client.attribute_type.Boolean -.. autoclass:: tamr_unify_client.attribute_type.Double -.. autoclass:: tamr_unify_client.attribute_type.Int -.. autoclass:: tamr_unify_client.attribute_type.Long -.. autoclass:: tamr_unify_client.attribute_type.String +See https://docs.tamr.com/reference#attribute-types + +.. autodata:: tamr_client.attribute_type.BOOLEAN +.. autodata:: tamr_client.attribute_type.DOUBLE +.. autodata:: tamr_client.attribute_type.INT +.. autodata:: tamr_client.attribute_type.LONG +.. autodata:: tamr_client.attribute_type.STRING .. NOTE(pcattori): Manually write docs for complex attribute types Complex types recursively reference other attribute types or subattributes sphinx_autodoc_typehints cannot properly parse types recursively -.. class:: tamr_unify_client.attribute_type.Array(inner_type) +.. class:: tamr_client.attribute_type.Array(inner_type) - See https://docs.tamr.com/reference#attribute-types - :param inner_type: - :type inner_type: :class:`~tamr_unify_client.AttributeType` - -.. class:: tamr_unify_client.attribute_type.Map(inner_type) + :type inner_type: :class:`~tamr_client.AttributeType` - See https://docs.tamr.com/reference#attribute-types +.. class:: tamr_client.attribute_type.Map(inner_type) :param inner_type: - :type inner_type: :class:`~tamr_unify_client.AttributeType` + :type inner_type: :class:`~tamr_client.AttributeType` -.. class:: tamr_unify_client.attribute_type.Record(attributes) +.. class:: tamr_client.attribute_type.Record(attributes) - See https://docs.tamr.com/reference#attribute-types + :param attributes: + :type attributes: :class:`~typing.Tuple` [:class:`~tamr_client.SubAttribute`] - :param attributes: - :type attributes: :class:`~typing.Tuple` [:class:`~tamr_unify_client.SubAttribute`] +.. autofunction:: tamr_client.attribute_type.from_json +.. autofunction:: tamr_client.attribute_type.to_json -.. autofunction:: tamr_unify_client.attribute_type.from_json -.. autofunction:: tamr_unify_client.attribute_type.to_json +Type aliases +^^^^^^^^^^^^ + +.. autodata:: tamr_client.attribute_type_alias.DEFAULT +.. autodata:: tamr_client.attribute_type_alias.GEOSPATIAL SubAttribute ------------ -.. autoclass:: tamr_unify_client.SubAttribute +.. class:: tamr_client.SubAttribute(name, type, is_nullable, description=None) + + :param name: + :type name: :class:`str` + :param type: + :type type: :class:`~tamr_client.AttributeType` + :param is_nullable: + :type is_nullable: :class:`bool` + :param description: + :type description: :class:`~typing.Optional` [:class:`str`] -.. autofunction:: tamr_unify_client.subattribute.from_json -.. autofunction:: tamr_unify_client.subattribute.to_json +.. autofunction:: tamr_client.subattribute.from_json +.. autofunction:: tamr_client.subattribute.to_json diff --git a/docs/reference/beta/datasets.rst b/docs/reference/beta/datasets.rst index c7dda181..d8d7c1dc 100644 --- a/docs/reference/beta/datasets.rst +++ b/docs/reference/beta/datasets.rst @@ -4,4 +4,4 @@ Datasets Dataset ------- -.. autofunction:: tamr_unify_client.datasets.dataset._attributes +.. autofunction:: tamr_client.dataset.attributes diff --git a/poetry.lock b/poetry.lock index 56c29504..b1028dd3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -369,7 +369,7 @@ description = "pytest: simple powerful testing with Python" name = "pytest" optional = false python-versions = ">=3.5" -version = "5.3.2" +version = "5.3.5" [package.dependencies] atomicwrites = ">=1.0" @@ -386,6 +386,7 @@ python = "<3.8" version = ">=0.12" [package.extras] +checkqa-mypy = ["mypy (v0.761)"] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] [[package]] @@ -886,8 +887,8 @@ pyparsing = [ {file = "pyparsing-2.4.0.tar.gz", hash = "sha256:1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a"}, ] pytest = [ - {file = "pytest-5.3.2-py3-none-any.whl", hash = "sha256:e41d489ff43948babd0fad7ad5e49b8735d5d55e26628a58673c39ff61d95de4"}, - {file = "pytest-5.3.2.tar.gz", hash = "sha256:6b571215b5a790f9b41f19f3531c53a45cf6bb8ef2988bc1ff9afb38270b25fa"}, + {file = "pytest-5.3.5-py3-none-any.whl", hash = "sha256:ff615c761e25eb25df19edddc0b970302d2a9091fbce0e7213298d85fb61fef6"}, + {file = "pytest-5.3.5.tar.gz", hash = "sha256:0d5fe9189a148acc3c3eb2ac8e1ac0742cb7618c084f3d228baaec0c254b318d"}, ] python-dateutil = [ {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, diff --git a/stubs/pytest.pyi b/stubs/pytest.pyi new file mode 100644 index 00000000..c8072cf8 --- /dev/null +++ b/stubs/pytest.pyi @@ -0,0 +1,3 @@ +from typing import Any + +def raises(expected_exception: Any): ... diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py new file mode 100644 index 00000000..7099d047 --- /dev/null +++ b/tamr_client/__init__.py @@ -0,0 +1,33 @@ +# flake8: noqa + +import logging + +# utilities +from tamr_client.url import URL +import tamr_client.url as url + +import tamr_client.response as response +from tamr_client.auth import UsernamePasswordAuth +from tamr_client.session import session + +# datasets +from tamr_client.datasets.dataset import Dataset +import tamr_client.datasets.dataset as dataset + +# attributes +from tamr_client.attributes.subattribute import SubAttribute +import tamr_client.attributes.subattribute as subattribute + +from tamr_client.attributes.attribute_type import AttributeType +import tamr_client.attributes.attribute_type as attribute_type + +import tamr_client.attributes.type_alias as attribute_type_alias + +from tamr_client.attributes.attribute import Attribute +from tamr_client.attributes.attribute import ReservedAttributeName +from tamr_client.attributes.attribute import AttributeExists +from tamr_client.attributes.attribute import AttributeNotFound +import tamr_client.attributes.attribute as attribute + +# https://docs.python-guide.org/writing/logging/#logging-in-a-library +logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/tamr_unify_client/attributes/attribute.py b/tamr_client/attributes/attribute.py similarity index 54% rename from tamr_unify_client/attributes/attribute.py rename to tamr_client/attributes/attribute.py index fb9ca4a7..41999a22 100644 --- a/tamr_unify_client/attributes/attribute.py +++ b/tamr_client/attributes/attribute.py @@ -4,9 +4,46 @@ from requests import Session -import tamr_unify_client as tc -from tamr_unify_client.dataset.resource import Dataset -from tamr_unify_client.JsonDict import JsonDict +import tamr_client as tc +from tamr_client.json_dict import JsonDict + +_RESERVED_NAMES = frozenset( + [ + # See javasrc/procurify/ui/app/scripts/constants/ElasticConstants.js + "origin_source_name", + "tamr_id", + "origin_entity_id", + # See javasrc/procurify/ui/app/scripts/constants/PipelineConstants.js + "clusterId", + "originSourceId", + "originEntityId", + "sourceId", + "entityId", + "suggestedClusterId", + "verificationType", + "verifiedClusterId", + ] +) + + +class AttributeNotFound(Exception): + """Raised when referencing (e.g. updating or deleting) an attribute + that does not exist on the server. + """ + + pass + + +class AttributeExists(Exception): + """Raised when trying to create an attribute that already exists on the server""" + + pass + + +class ReservedAttributeName(Exception): + """Raised when attempting to create an attribute with a reserved name""" + + pass @dataclass(frozen=True) @@ -24,13 +61,13 @@ class Attribute: url: tc.URL name: str - type: tc.attribute_type.AttributeType + type: tc.AttributeType is_nullable: bool _json: JsonDict = field(compare=False, repr=False) description: Optional[str] = None -def from_resource_id(session: Session, dataset: Dataset, id: str) -> Attribute: +def from_resource_id(session: Session, dataset: tc.Dataset, id: str) -> Attribute: """Get attribute by resource ID Fetches attribute from Tamr server @@ -40,9 +77,11 @@ def from_resource_id(session: Session, dataset: Dataset, id: str) -> Attribute: id: Attribute ID Raises: - requests.HTTPError: If an HTTP error is encountered. + AttributeNotFound: If no attribute could be found at the specified URL. + Corresponds to a 404 HTTP error. + requests.HTTPError: If any other HTTP error is encountered. """ - url = replace(dataset.url, path=dataset.url.path + f"/{id}") + url = replace(dataset.url, path=dataset.url.path + f"/attributes/{id}") return _from_url(session, url) @@ -55,9 +94,13 @@ def _from_url(session: Session, url: tc.URL) -> Attribute: url: Attribute URL Raises: - requests.HTTPError: If an HTTP error is encountered. + AttributeNotFound: If no attribute could be found at the specified URL. + Corresponds to a 404 HTTP error. + requests.HTTPError: If any other HTTP error is encountered. """ r = session.get(str(url)) + if r.status_code == 404: + raise AttributeNotFound(str(url)) data = tc.response.successful(r).json() return _from_json(url, data) @@ -101,11 +144,11 @@ def to_json(attr: Attribute) -> JsonDict: def create( session: Session, - dataset: Dataset, + dataset: tc.dataset.Dataset, *, name: str, - type: tc.attribute_type.AttributeType, is_nullable: bool, + type: tc.attribute_type.AttributeType = tc.attribute_type_alias.DEFAULT, description: Optional[str] = None, ) -> Attribute: """Create an attribute @@ -118,14 +161,44 @@ def create( type: Attribute type for the new attribute is_nullable: Determines if the new attribute can contain NULL values description: Description of the new attribute + force: If `True`, skips reserved attribute name check Returns: The newly created attribute Raises: - requests.HTTPError: If an HTTP error is encountered. + ReservedAttributeName: If attribute name is reserved. + AttributeExists: If an attribute already exists at the specified URL. + Corresponds to a 409 HTTP error. + requests.HTTPError: If any other HTTP error is encountered. + """ + if name in _RESERVED_NAMES: + raise ReservedAttributeName(name) + + return _create( + session, + dataset, + name=name, + is_nullable=is_nullable, + type=type, + description=description, + ) + + +def _create( + session: Session, + dataset: tc.dataset.Dataset, + *, + name: str, + is_nullable: bool, + type: tc.attribute_type.AttributeType = tc.attribute_type_alias.DEFAULT, + description: Optional[str] = None, +) -> Attribute: + """Same as `tc.attribute.create`, but does not check for reserved attribute + names. """ attrs_url = replace(dataset.url, path=dataset.url.path + "/attributes") + url = replace(attrs_url, path=attrs_url.path + f"/{name}") body = { "name": name, @@ -136,9 +209,10 @@ def create( body["description"] = description r = session.post(str(attrs_url), json=body) + if r.status_code == 409: + raise AttributeExists(str(url)) data = tc.response.successful(r).json() - name = data["name"] - url = replace(attrs_url, path=attrs_url.path + f"/{name}") + return _from_json(url, data) @@ -157,10 +231,14 @@ def update( The newly updated attribute Raises: - requests.HTTPError: If an HTTP error is encountered. + AttributeNotFound: If no attribute could be found at the specified URL. + Corresponds to a 404 HTTP error. + requests.HTTPError: If any other HTTP error is encountered. """ updates = {"description": description} r = session.put(str(attribute.url), json=updates) + if r.status_code == 404: + raise AttributeNotFound(str(attribute.url)) data = tc.response.successful(r).json() return _from_json(attribute.url, data) @@ -174,7 +252,11 @@ def delete(session: Session, attribute: Attribute): attribute: Existing attribute to delete Raises: - requests.HTTPError: If an HTTP error is encountered. + AttributeNotFound: If no attribute could be found at the specified URL. + Corresponds to a 404 HTTP error. + requests.HTTPError: If any other HTTP error is encountered. """ r = session.delete(str(attribute.url)) + if r.status_code == 404: + raise AttributeNotFound(str(attribute.url)) tc.response.successful(r) diff --git a/tamr_unify_client/attributes/attribute_type.py b/tamr_client/attributes/attribute_type.py similarity index 86% rename from tamr_unify_client/attributes/attribute_type.py rename to tamr_client/attributes/attribute_type.py index 9c39b59e..a035ed7d 100644 --- a/tamr_unify_client/attributes/attribute_type.py +++ b/tamr_client/attributes/attribute_type.py @@ -1,9 +1,12 @@ +""" +See https://docs.tamr.com/reference#attribute-types +""" from dataclasses import dataclass import logging -from typing import ClassVar, Tuple, Type, Union +from typing import ClassVar, Tuple, Union -import tamr_unify_client as tc -from tamr_unify_client.JsonDict import JsonDict +import tamr_client as tc +from tamr_client.json_dict import JsonDict logger = logging.getLogger(__name__) @@ -13,36 +16,26 @@ @dataclass(frozen=True) class Boolean: - """See https://docs.tamr.com/reference#attribute-types""" - _tag: ClassVar[str] = "BOOLEAN" @dataclass(frozen=True) class Double: - """See https://docs.tamr.com/reference#attribute-types""" - _tag: ClassVar[str] = "DOUBLE" @dataclass(frozen=True) class Int: - """See https://docs.tamr.com/reference#attribute-types""" - _tag: ClassVar[str] = "INT" @dataclass(frozen=True) class Long: - """See https://docs.tamr.com/reference#attribute-types""" - _tag: ClassVar[str] = "LONG" @dataclass(frozen=True) class String: - """See https://docs.tamr.com/reference#attribute-types""" - _tag: ClassVar[str] = "STRING" @@ -79,7 +72,7 @@ class Record: # NOTE(pcattori) sphinx_autodoc_typehints cannot handle recursive reference # docstring written manually _tag: ClassVar[str] = "RECORD" - attributes: Tuple["tc.SubAttribute", ...] + attributes: Tuple[tc.SubAttribute, ...] ComplexType = Union[Array, Map, Record] @@ -101,15 +94,15 @@ def from_json(data: JsonDict) -> AttributeType: logger.error(f"JSON data: {repr(data)}") raise ValueError("Missing required field 'baseType'.") if base_type == Boolean._tag: - return Boolean() + return BOOLEAN elif base_type == Double._tag: - return Double() + return DOUBLE elif base_type == Int._tag: - return Int() + return INT elif base_type == Long._tag: - return Long() + return LONG elif base_type == String._tag: - return String() + return STRING elif base_type == Array._tag: inner_type = data.get("innerType") if inner_type is None: @@ -158,3 +151,12 @@ def to_json(attr_type: AttributeType) -> JsonDict: } else: raise TypeError(attr_type) + + +# Singletons + +BOOLEAN = Boolean() +DOUBLE = Double() +INT = Int() +LONG = Long() +STRING = String() diff --git a/tamr_unify_client/attributes/subattribute.py b/tamr_client/attributes/subattribute.py similarity index 87% rename from tamr_unify_client/attributes/subattribute.py rename to tamr_client/attributes/subattribute.py index ec16c3b0..6b4c9586 100644 --- a/tamr_unify_client/attributes/subattribute.py +++ b/tamr_client/attributes/subattribute.py @@ -1,9 +1,9 @@ from copy import deepcopy -from dataclasses import dataclass, field -from typing import Any, Dict, Optional +from dataclasses import dataclass +from typing import Optional -import tamr_unify_client as tc -from tamr_unify_client.JsonDict import JsonDict +import tamr_client as tc +from tamr_client.json_dict import JsonDict @dataclass(frozen=True) @@ -20,7 +20,7 @@ class SubAttribute: """ name: str - type: tc.AttributeType + type: "tc.AttributeType" is_nullable: bool description: Optional[str] = None diff --git a/tamr_client/attributes/type_alias.py b/tamr_client/attributes/type_alias.py new file mode 100644 index 00000000..eee71cab --- /dev/null +++ b/tamr_client/attributes/type_alias.py @@ -0,0 +1,23 @@ +import tamr_client as tc +from tamr_client.attributes.attribute_type import Array, DOUBLE, Record, STRING + +DEFAULT = Array(STRING) + +GEOSPATIAL = Record( + attributes=( + tc.SubAttribute(name="point", is_nullable=True, type=Array(DOUBLE)), + tc.SubAttribute(name="multiPoint", is_nullable=True, type=Array(Array(DOUBLE))), + tc.SubAttribute(name="lineString", is_nullable=True, type=Array(Array(DOUBLE))), + tc.SubAttribute( + name="multiLineString", is_nullable=True, type=Array(Array(Array(DOUBLE))) + ), + tc.SubAttribute( + name="polygon", is_nullable=True, type=Array(Array(Array(DOUBLE))) + ), + tc.SubAttribute( + name="multiPolygon", + is_nullable=True, + type=Array(Array(Array(Array(DOUBLE)))), + ), + ) +) diff --git a/tamr_client/auth.py b/tamr_client/auth.py new file mode 100644 index 00000000..4b2cf867 --- /dev/null +++ b/tamr_client/auth.py @@ -0,0 +1,40 @@ +import base64 + +import requests + + +def _basic_auth_str(username, password): + auth = f"{username}:{password}" + encoded = base64.b64encode(auth.encode("latin1")) + return "BasicCreds " + requests.utils.to_native_string(encoded.strip()) + + +class UsernamePasswordAuth(requests.auth.HTTPBasicAuth): + """Provides username/password authentication for Tamr. + + Sets the `Authorization` HTTP header with Tamr's custom `BasicCreds` format. + + Args: + username: + password: + + Example: + >>> import tamr_client as tc + >>> auth = tc.UsernamePasswordAuth('my username', 'my password') + >>> s = tc.Session(auth) + """ + + def __init__(self, username: str, password: str): + super().__init__(username, password) + + def __call__(self, r): + r.headers["Authorization"] = _basic_auth_str(self.username, self.password) + return r + + def __repr__(self): + # intentionally leave out password (potentially sensitive) + return ( + f"{type(self).__qualname__}(" + f"username={repr(self.username)}" + f"password=)" + ) diff --git a/tamr_unify_client/datasets/dataset.py b/tamr_client/datasets/dataset.py similarity index 74% rename from tamr_unify_client/datasets/dataset.py rename to tamr_client/datasets/dataset.py index 571d9e9d..ccd63fc5 100644 --- a/tamr_unify_client/datasets/dataset.py +++ b/tamr_client/datasets/dataset.py @@ -1,13 +1,18 @@ -from dataclasses import replace +from dataclasses import dataclass, replace from typing import Tuple from requests import Session -import tamr_unify_client as tc -from tamr_unify_client.dataset.resource import Dataset +import tamr_client as tc -def _attributes(session: Session, dataset: Dataset) -> Tuple[tc.Attribute, ...]: +@dataclass(frozen=True) +class Dataset: + url: tc.URL + key_attribute_names: Tuple[str, ...] + + +def attributes(session: Session, dataset: Dataset) -> Tuple["tc.Attribute", ...]: """Get attributes for this dataset Args: diff --git a/tamr_unify_client/JsonDict.py b/tamr_client/json_dict.py similarity index 100% rename from tamr_unify_client/JsonDict.py rename to tamr_client/json_dict.py diff --git a/tamr_client/response.py b/tamr_client/response.py new file mode 100644 index 00000000..f06aec3a --- /dev/null +++ b/tamr_client/response.py @@ -0,0 +1,27 @@ +import logging + +import requests + +logger = logging.getLogger(__name__) + + +def successful(response: requests.Response) -> requests.Response: + """Ensure response does not contain an HTTP error. + + Delegates to :func:`requests.Response.raise_for_status` + + Returns: + The response being checked. + + Raises: + requests.exceptions.HTTPError: If an HTTP error is encountered. + """ + try: + response.raise_for_status() + except requests.HTTPError as e: + r = e.response + logger.error( + f"Encountered HTTP error code {r.status_code}. Response body: {r.text}" + ) + raise e + return response diff --git a/tamr_client/session.py b/tamr_client/session.py new file mode 100644 index 00000000..f096e63e --- /dev/null +++ b/tamr_client/session.py @@ -0,0 +1,12 @@ +import requests + + +def session(auth: requests.auth.HTTPBasicAuth, **kwargs) -> requests.Session: + """Create a new authenticated session + + Args: + auth: Authentication + """ + s = requests.Session(**kwargs) + s.auth = auth + return s diff --git a/tamr_unify_client/url.py b/tamr_client/url.py similarity index 79% rename from tamr_unify_client/url.py rename to tamr_client/url.py index 6616d13c..68d6b7f7 100644 --- a/tamr_unify_client/url.py +++ b/tamr_client/url.py @@ -7,6 +7,7 @@ class URL: protocol: str = "http" host: str = "localhost" port: int = 9100 + base_path: str = "api/versioned/v1" def __str__(self): - return f"{self.protocol}://{self.host}:{self.port}/{self.path}" + return f"{self.protocol}://{self.host}:{self.port}/{self.base_path}/{self.path}" diff --git a/tamr_unify_client/__init__.py b/tamr_unify_client/__init__.py index 121f7bc9..7fd0379c 100644 --- a/tamr_unify_client/__init__.py +++ b/tamr_unify_client/__init__.py @@ -3,22 +3,3 @@ import logging from tamr_unify_client.client import Client - -# utilities -from tamr_unify_client.url import URL -import tamr_unify_client.response as response -import tamr_unify_client.url as url - -# attributes -from tamr_unify_client.attributes.attribute_type import AttributeType -import tamr_unify_client.attributes.attribute_type as attribute_type -from tamr_unify_client.attributes.subattribute import SubAttribute -import tamr_unify_client.attributes.subattribute as subattribute -from tamr_unify_client.attributes.attribute import Attribute -import tamr_unify_client.attributes.attribute as attribute - -# datasets -import tamr_unify_client.datasets.dataset as dataset - -# https://docs.python-guide.org/writing/logging/#logging-in-a-library -logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/tamr_unify_client/attribute/subattribute.py b/tamr_unify_client/attribute/subattribute.py index ddfc7b8e..2377c89f 100644 --- a/tamr_unify_client/attribute/subattribute.py +++ b/tamr_unify_client/attribute/subattribute.py @@ -42,4 +42,4 @@ def from_json(data: SubAttributeJson) -> "SubAttribute": # TODO implement AttributeType.from_json and use that instead type = AttributeType(type_json) - return SubAttribute(**dc, type=type, _json=_json) # type: ignore + return SubAttribute(**dc, type=type, _json=_json) diff --git a/tamr_unify_client/auth/__init__.py b/tamr_unify_client/auth/__init__.py index 55bc40ac..36790060 100644 --- a/tamr_unify_client/auth/__init__.py +++ b/tamr_unify_client/auth/__init__.py @@ -1,2 +1,4 @@ +# flake8: noqa + from tamr_unify_client.auth.token import TokenAuth from tamr_unify_client.auth.username_password import UsernamePasswordAuth diff --git a/tamr_unify_client/base_collection.py b/tamr_unify_client/base_collection.py index 6326644f..fd45e02f 100644 --- a/tamr_unify_client/base_collection.py +++ b/tamr_unify_client/base_collection.py @@ -30,7 +30,7 @@ def by_resource_id(self, canonical_path, resource_id): :rtype: The ``resource_class`` for this collection. See :func:`~tamr_unify_client.base_collection.BaseCollection.by_relative_id`. """ relative_id = canonical_path + "/" + resource_id - return self.by_relative_id(relative_id) # type: ignore + return self.by_relative_id(relative_id) @abstractmethod def by_relative_id(self, resource_class, relative_id): @@ -68,7 +68,7 @@ def stream(self, resource_class): yield resource_class.from_json(self.client, resource_json) def __iter__(self): - return self.stream() # type: ignore + return self.stream() @abstractmethod def by_external_id(self, resource_class, external_id): diff --git a/tamr_unify_client/categorization/category/resource.py b/tamr_unify_client/categorization/category/resource.py index 8f657ebd..90978383 100644 --- a/tamr_unify_client/categorization/category/resource.py +++ b/tamr_unify_client/categorization/category/resource.py @@ -23,7 +23,7 @@ def description(self): @property def path(self): """:type: list[str]""" - return self._data.get("path")[:] # type: ignore + return self._data.get("path")[:] def parent(self): """Gets the parent Category of this one, or None if it is a tier 1 category diff --git a/tamr_unify_client/client.py b/tamr_unify_client/client.py index 54689cf6..e788939d 100644 --- a/tamr_unify_client/client.py +++ b/tamr_unify_client/client.py @@ -8,10 +8,12 @@ from tamr_unify_client.dataset.collection import DatasetCollection from tamr_unify_client.project.collection import ProjectCollection -import tamr_unify_client.response # monkey-patch requests.Response.successful +import tamr_unify_client.response as response logger = logging.getLogger(__name__) +response._monkey_patch() + class Client: """Python Client for Tamr API. diff --git a/tamr_unify_client/dataset/profile.py b/tamr_unify_client/dataset/profile.py index 7eac7d88..35630d4d 100644 --- a/tamr_unify_client/dataset/profile.py +++ b/tamr_unify_client/dataset/profile.py @@ -15,7 +15,7 @@ def dataset_name(self) -> str: :type: str """ - return self._data.get("datasetName") # type: ignore + return self._data.get("datasetName") @property def relative_dataset_id(self) -> str: @@ -23,7 +23,7 @@ def relative_dataset_id(self) -> str: :type: str """ - return self._data.get("relativeDatasetId") # type: ignore + return self._data.get("relativeDatasetId") @property def is_up_to_date(self) -> bool: @@ -31,7 +31,7 @@ def is_up_to_date(self) -> bool: :type: bool """ - return self._data.get("isUpToDate") # type: ignore + return self._data.get("isUpToDate") @property def profiled_data_version(self) -> str: @@ -39,7 +39,7 @@ def profiled_data_version(self) -> str: :type: str """ - return self._data.get("profiledDataVersion") # type: ignore + return self._data.get("profiledDataVersion") @property def profiled_at(self) -> dict: @@ -47,7 +47,7 @@ def profiled_at(self) -> dict: :type: dict """ - return self._data.get("profiledAt") # type: ignore + return self._data.get("profiledAt") @property def simple_metrics(self) -> list: @@ -55,7 +55,7 @@ def simple_metrics(self) -> list: :type: list """ - return self._data.get("simpleMetrics") # type: ignore + return self._data.get("simpleMetrics") @property def attribute_profiles(self) -> list: @@ -63,7 +63,7 @@ def attribute_profiles(self) -> list: :type: list """ - return self._data.get("attributeProfiles") # type: ignore + return self._data.get("attributeProfiles") def refresh(self, **options): """Updates the dataset profile if needed. diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index 1a5a75ee..3f3eb36c 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -9,7 +9,6 @@ from tamr_unify_client.dataset.uri import DatasetURI from tamr_unify_client.dataset.usage import DatasetUsage from tamr_unify_client.operation import Operation -from tamr_unify_client.url import URL class Dataset(BaseResource): @@ -42,12 +41,12 @@ def version(self): @property def tags(self): """:type: list[str]""" - return self._data.get("tags")[:] # type: ignore + return self._data.get("tags")[:] @property def key_attribute_names(self): """:type: list[str]""" - return self._data.get("keyAttributeNames")[:] # type: ignore + return self._data.get("keyAttributeNames")[:] @property def attributes(self): @@ -59,20 +58,6 @@ def attributes(self): alias = self.api_path + "/attributes" return AttributeCollection(self.client, alias) - @property - def url(self) -> URL: - """URL for this dataset - - Note: URL will be the alias used to retrieve this dataset - (e.g. ``https://localhost:9100/api/versioned/v1/projects/1/unifiedDataset``) - which might not be the same as the canonical URL (e.g. ``https://localhost:9100/api/versioned/v1/datasets/3``) - - Returns: - URL used to retrieve this dataset - """ - c = self.client - return URL(protocol=c.protocol, host=c.host, port=c.port, path=self.api_path) - def _update_records(self, updates, **json_args): """Send a batch of record creations/updates/deletions to this dataset. You probably want to use :func:`~tamr_unify_client.dataset.resource.Dataset.upsert_records` diff --git a/tamr_unify_client/dataset/status.py b/tamr_unify_client/dataset/status.py index d41dd318..d554983e 100644 --- a/tamr_unify_client/dataset/status.py +++ b/tamr_unify_client/dataset/status.py @@ -14,7 +14,7 @@ def dataset_name(self) -> str: :type: str """ - return self._data.get("datasetName") # type: ignore + return self._data.get("datasetName") @property def relative_dataset_id(self) -> str: @@ -22,7 +22,7 @@ def relative_dataset_id(self) -> str: :type: str """ - return self._data.get("relativeDatasetId") # type: ignore + return self._data.get("relativeDatasetId") @property def is_streamable(self) -> bool: @@ -30,7 +30,7 @@ def is_streamable(self) -> bool: :type: bool """ - return self._data.get("isStreamable") # type: ignore + return self._data.get("isStreamable") def __repr__(self) -> str: return ( diff --git a/tamr_unify_client/dataset/usage.py b/tamr_unify_client/dataset/usage.py index f836c567..c580f8ce 100644 --- a/tamr_unify_client/dataset/usage.py +++ b/tamr_unify_client/dataset/usage.py @@ -27,7 +27,7 @@ def usage(self): def dependencies(self): """:type: list[:class:`~tamr_unify_client.dataset.use.DatasetUse`]""" deps = self._data.get("dependencies") - return [DatasetUse(self.client, dep) for dep in deps] # type: ignore + return [DatasetUse(self.client, dep) for dep in deps] def __repr__(self): return ( diff --git a/tamr_unify_client/mastering/estimated_pair_counts.py b/tamr_unify_client/mastering/estimated_pair_counts.py index d96c725a..43c84293 100644 --- a/tamr_unify_client/mastering/estimated_pair_counts.py +++ b/tamr_unify_client/mastering/estimated_pair_counts.py @@ -15,7 +15,7 @@ def is_up_to_date(self) -> bool: :rtype: bool """ - return self._data.get("isUpToDate") # type: ignore + return self._data.get("isUpToDate") @property def total_estimate(self) -> dict: @@ -29,7 +29,7 @@ def total_estimate(self) -> dict: } :rtype: dict[str, str] """ - return self._data.get("totalEstimate") # type: ignore + return self._data.get("totalEstimate") @property def clause_estimates(self) -> dict: @@ -49,7 +49,7 @@ def clause_estimates(self) -> dict: } :rtype: dict[str, dict[str, str]] """ - return self._data.get("clauseEstimates") # type: ignore + return self._data.get("clauseEstimates") def refresh(self, **options): """Updates the estimated pair counts if needed. diff --git a/tamr_unify_client/response.py b/tamr_unify_client/response.py index 9fbc85ae..ac9bc349 100644 --- a/tamr_unify_client/response.py +++ b/tamr_unify_client/response.py @@ -27,5 +27,5 @@ def successful(response: requests.Response) -> requests.Response: return response -# monkey-patch requests.Response.successful -requests.Response.successful = successful # type: ignore +def _monkey_patch(): + requests.Response.successful = successful diff --git a/tasks.py b/tasks.py index 26bbaca9..57aa0953 100644 --- a/tasks.py +++ b/tasks.py @@ -17,7 +17,7 @@ def format(c, fix=False): @task def typecheck(c, warn=True): repo = Path(".") - tc = repo / "tamr_unify_client" + tc = repo / "tamr_client" tests = repo / "tests" pkgs = [ tc / "attributes", diff --git a/tests/attributes/test_attribute.py b/tests/attributes/test_attribute.py index 3b09b06a..c5a80bda 100644 --- a/tests/attributes/test_attribute.py +++ b/tests/attributes/test_attribute.py @@ -1,24 +1,18 @@ from dataclasses import replace -import json -from pathlib import Path -from requests import Session +import pytest import responses -import tamr_unify_client as tc -from tamr_unify_client.auth import UsernamePasswordAuth -from tamr_unify_client.dataset.resource import Dataset -from tests.utils import data_dir, load_json +import tamr_client as tc +import tests.utils as utils def test_from_json(): - attrs_json = load_json(data_dir / "attributes.json") + attrs_json = utils.load_json("attributes.json") dataset_id = 1 for attr_json in attrs_json: attr_id = attr_json["name"] - url = tc.URL( - path=f"api/versioned/v1/datasets/{dataset_id}/attributes/{attr_id}" - ) + url = tc.URL(path=f"datasets/{dataset_id}/attributes/{attr_id}") attr = tc.attribute._from_json(url, attr_json) assert attr.name == attr_json["name"] assert attr.description == attr_json["description"] @@ -27,17 +21,20 @@ def test_from_json(): def test_json(): """original -> to_json -> from_json -> original""" - attrs_json = load_json(data_dir / "attributes.json") + attrs_json = utils.load_json("attributes.json") dataset_id = 1 for attr_json in attrs_json: attr_id = attr_json["name"] - url = tc.URL(f"api/versioned/v1/datasets/{dataset_id}/attributes/{attr_id}") + url = tc.URL(f"datasets/{dataset_id}/attributes/{attr_id}") attr = tc.attribute._from_json(url, attr_json) assert attr == tc.attribute._from_json(url, tc.attribute.to_json(attr)) @responses.activate def test_create(): + s = utils.session() + dataset = utils.dataset() + attrs = tuple( [ tc.SubAttribute( @@ -49,37 +46,34 @@ def test_create(): ] ) - auth = UsernamePasswordAuth("username", "password") - tamr = tc.Client(auth) - dataset_json = load_json(data_dir / "dataset.json") - dataset_url = tc.URL(path="api/versioned/v1/datasets/1") - dataset = Dataset.from_json(tamr, dataset_json, api_path=dataset_url.path) - - url = tc.URL(path=dataset.url.path + "/attributes") - attr_url = replace(url, path=url.path + "/attr") - attr_json = load_json(data_dir / "attribute.json") - responses.add(responses.POST, str(url), json=attr_json) + attrs_url = tc.URL(path=dataset.url.path + "/attributes") + url = replace(attrs_url, path=attrs_url.path + "/attr") + attr_json = utils.load_json("attribute.json") + responses.add(responses.POST, str(attrs_url), json=attr_json) attr = tc.attribute.create( - Session(), + s, dataset, name="attr", is_nullable=False, type=tc.attribute_type.Record(attributes=attrs), ) - assert attr == tc.attribute._from_json(attr_url, attr_json) + assert attr == tc.attribute._from_json(url, attr_json) @responses.activate def test_update(): - attr_url = tc.URL(path="api/versioned/v1/datasets/1/attributes/RowNum") - attr_json = load_json(data_dir / "attributes.json")[0] - attr = tc.attribute._from_json(attr_url, attr_json) + auth = tc.UsernamePasswordAuth("username", "password") + s = tc.session(auth) - updated_attr_json = load_json(data_dir / "updated_attribute.json") - responses.add(responses.PUT, str(attr_url), json=updated_attr_json) + url = tc.URL(path="datasets/1/attributes/RowNum") + attr_json = utils.load_json("attributes.json")[0] + attr = tc.attribute._from_json(url, attr_json) + + updated_attr_json = utils.load_json("updated_attribute.json") + responses.add(responses.PUT, str(attr.url), json=updated_attr_json) updated_attr = tc.attribute.update( - Session(), attr, description=updated_attr_json["description"] + s, attr, description=updated_attr_json["description"] ) assert updated_attr == replace(attr, description=updated_attr_json["description"]) @@ -87,9 +81,82 @@ def test_update(): @responses.activate def test_delete(): - attr_url = tc.URL(path="api/versioned/v1/datasets/1/attributes/RowNum") - attr_json = load_json(data_dir / "attributes.json")[0] - attr = tc.attribute._from_json(attr_url, attr_json) + auth = tc.UsernamePasswordAuth("username", "password") + s = tc.session(auth) + + url = tc.URL(path="datasets/1/attributes/RowNum") + attr_json = utils.load_json("attributes.json")[0] + attr = tc.attribute._from_json(url, attr_json) + + responses.add(responses.DELETE, str(attr.url), status=204) + tc.attribute.delete(s, attr) + + +@responses.activate +def test_from_resource_id(): + s = utils.session() + dataset = utils.dataset() + + url = tc.URL(path=dataset.url.path + "/attributes/attr") + attr_json = utils.load_json("attribute.json") + responses.add(responses.GET, str(url), json=attr_json) + attr = tc.attribute.from_resource_id(s, dataset, "attr") + + assert attr == tc.attribute._from_json(url, attr_json) + + +@responses.activate +def test_from_resource_id_attribute_not_found(): + s = utils.session() + dataset = utils.dataset() + + url = replace(dataset.url, path=dataset.url.path + "/attributes/attr") + + responses.add(responses.GET, str(url), status=404) + with pytest.raises(tc.AttributeNotFound): + tc.attribute.from_resource_id(s, dataset, "attr") + + +def test_create_reserved_attribute_name(): + s = utils.session() + dataset = utils.dataset() + + with pytest.raises(tc.ReservedAttributeName): + tc.attribute.create(s, dataset, name="clusterId", is_nullable=False) + + +@responses.activate +def test_create_attribute_exists(): + s = utils.session() + dataset = utils.dataset() + + url = replace(dataset.url, path=dataset.url.path + "/attributes") + responses.add(responses.POST, str(url), status=409) + with pytest.raises(tc.AttributeExists): + tc.attribute.create(s, dataset, name="attr", is_nullable=False) + + +@responses.activate +def test_update_attribute_not_found(): + s = utils.session() + + url = tc.URL(path="datasets/1/attributes/RowNum") + attr_json = utils.load_json("attributes.json")[0] + attr = tc.attribute._from_json(url, attr_json) + + responses.add(responses.PUT, str(attr.url), status=404) + with pytest.raises(tc.AttributeNotFound): + tc.attribute.update(s, attr) + + +@responses.activate +def test_delete_attribute_not_found(): + s = utils.session() + + url = tc.URL(path="datasets/1/attributes/RowNum") + attr_json = utils.load_json("attributes.json")[0] + attr = tc.attribute._from_json(url, attr_json) - responses.add(responses.DELETE, str(attr_url), status=204) - tc.attribute.delete(Session(), attr) + responses.add(responses.PUT, str(attr.url), status=404) + with pytest.raises(tc.AttributeNotFound): + attr = tc.attribute.update(s, attr) diff --git a/tests/attributes/test_attribute_type.py b/tests/attributes/test_attribute_type.py index 042440c5..793f3bb4 100644 --- a/tests/attributes/test_attribute_type.py +++ b/tests/attributes/test_attribute_type.py @@ -1,12 +1,9 @@ -import json -from pathlib import Path - -import tamr_unify_client as tc -from tests.utils import data_dir, load_json +import tamr_client as tc +import tests.utils def test_from_json(): - geom_json = load_json(data_dir / "attributes.json")[1] + geom_json = tests.utils.load_json("attributes.json")[1] geom_type = tc.attribute_type.from_json(geom_json["type"]) assert isinstance(geom_type, tc.attribute_type.Record) @@ -36,7 +33,7 @@ def test_from_json(): def test_json(): - attrs_json = load_json(data_dir / "attributes.json") + attrs_json = tests.utils.load_json("attributes.json") for attr_json in attrs_json: attr_type_json = attr_json["type"] attr_type = tc.attribute_type.from_json(attr_type_json) diff --git a/tests/datasets/test_dataset.py b/tests/datasets/test_dataset.py index 328d0938..578b1386 100644 --- a/tests/datasets/test_dataset.py +++ b/tests/datasets/test_dataset.py @@ -1,27 +1,21 @@ from dataclasses import replace -from requests import Session import responses -import tamr_unify_client as tc -from tamr_unify_client.auth import UsernamePasswordAuth -from tamr_unify_client.dataset.resource import Dataset -from tests.utils import data_dir, load_json +import tamr_client as tc +import tests.utils as utils @responses.activate -def test__attributes(): - auth = UsernamePasswordAuth("username", "password") - tamr = tc.Client(auth) - dataset_json = load_json(data_dir / "dataset.json") - dataset_url = tc.URL(path="api/versioned/v1/datasets/1") - dataset = Dataset.from_json(tamr, dataset_json, api_path=dataset_url.path) +def test_attributes(): + s = utils.session() + dataset = utils.dataset() attrs_url = replace(dataset.url, path=dataset.url.path + "/attributes") - attrs_json = load_json(data_dir / "attributes.json") + attrs_json = utils.load_json("attributes.json") responses.add(responses.GET, str(attrs_url), json=attrs_json, status=204) - attrs = tc.dataset._attributes(Session(), dataset) + attrs = tc.dataset.attributes(s, dataset) row_num = attrs[0] assert row_num.name == "RowNum" diff --git a/tests/unit/test_dataset_by_external_id.py b/tests/unit/test_dataset_by_external_id.py index c68489e0..c5350e41 100644 --- a/tests/unit/test_dataset_by_external_id.py +++ b/tests/unit/test_dataset_by_external_id.py @@ -1,5 +1,3 @@ -import json - import pytest import responses diff --git a/tests/unit/test_dataset_status.py b/tests/unit/test_dataset_status.py index 6c769dd2..93b6632c 100644 --- a/tests/unit/test_dataset_status.py +++ b/tests/unit/test_dataset_status.py @@ -1,5 +1,3 @@ -import json - import responses from tamr_unify_client import Client diff --git a/tests/utils.py b/tests/utils.py index ced32e8e..09d34362 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,9 +1,24 @@ import json from pathlib import Path +from typing import Union + +import tamr_client as tc data_dir = Path(__file__).parent / "data" -def load_json(path: Path): - with open(path) as f: +def load_json(path: Union[str, Path]): + with open(data_dir / path) as f: return json.load(f) + + +def session(): + auth = tc.UsernamePasswordAuth("username", "password") + s = tc.session(auth) + return s + + +def dataset(): + url = tc.URL(path="datasets/1") + dataset = tc.Dataset(url, key_attribute_names=("primary_key",)) + return dataset From 0013658aa0c8a954a2e1226faf886e41da24d8cb Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 13 Feb 2020 11:57:58 -0500 Subject: [PATCH 250/632] Include type annotations from tamr_client package --- pyproject.toml | 1 + tamr_client/py.typed | 0 2 files changed, 1 insertion(+) create mode 100644 tamr_client/py.typed diff --git a/pyproject.toml b/pyproject.toml index 7bf9d20d..af6ccc85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8" ] +include = ["tamr_client/py.typed"] [tool.poetry.dependencies] python = "^3.6" diff --git a/tamr_client/py.typed b/tamr_client/py.typed new file mode 100644 index 00000000..e69de29b From 7faaf5ee721d4c7e97aa8aef925376fb408c6af9 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 13 Feb 2020 12:33:46 -0500 Subject: [PATCH 251/632] explicitly declare packages Previously, tamr_client package was not being included in the distribution --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index af6ccc85..93eafa9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,10 @@ classifiers = [ "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8" ] +packages = [ + { include = "tamr_client" }, + { include = "tamr_unify_client" }, +] include = ["tamr_client/py.typed"] [tool.poetry.dependencies] From 6262cc860fcdc120c928185d808704901a21a2c1 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 13 Feb 2020 13:46:49 -0500 Subject: [PATCH 252/632] Session module redesign tc.Session as a wrapper around requests.Session tc.session.from_auth to create new sessions --- tamr_client/__init__.py | 3 ++- tamr_client/attributes/attribute.py | 14 ++++++-------- tamr_client/datasets/dataset.py | 4 +--- tamr_client/session.py | 4 +++- tests/attributes/test_attribute.py | 6 ++---- tests/utils.py | 2 +- 6 files changed, 15 insertions(+), 18 deletions(-) diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 7099d047..3d583e07 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -8,7 +8,8 @@ import tamr_client.response as response from tamr_client.auth import UsernamePasswordAuth -from tamr_client.session import session +from tamr_client.session import Session +import tamr_client.session as session # datasets from tamr_client.datasets.dataset import Dataset diff --git a/tamr_client/attributes/attribute.py b/tamr_client/attributes/attribute.py index 41999a22..31673156 100644 --- a/tamr_client/attributes/attribute.py +++ b/tamr_client/attributes/attribute.py @@ -2,8 +2,6 @@ from dataclasses import dataclass, field, replace from typing import Optional -from requests import Session - import tamr_client as tc from tamr_client.json_dict import JsonDict @@ -67,7 +65,7 @@ class Attribute: description: Optional[str] = None -def from_resource_id(session: Session, dataset: tc.Dataset, id: str) -> Attribute: +def from_resource_id(session: tc.Session, dataset: tc.Dataset, id: str) -> Attribute: """Get attribute by resource ID Fetches attribute from Tamr server @@ -85,7 +83,7 @@ def from_resource_id(session: Session, dataset: tc.Dataset, id: str) -> Attribut return _from_url(session, url) -def _from_url(session: Session, url: tc.URL) -> Attribute: +def _from_url(session: tc.Session, url: tc.URL) -> Attribute: """Get attribute by URL Fetches attribute from Tamr server @@ -143,7 +141,7 @@ def to_json(attr: Attribute) -> JsonDict: def create( - session: Session, + session: tc.Session, dataset: tc.dataset.Dataset, *, name: str, @@ -186,7 +184,7 @@ def create( def _create( - session: Session, + session: tc.Session, dataset: tc.dataset.Dataset, *, name: str, @@ -217,7 +215,7 @@ def _create( def update( - session: Session, attribute: Attribute, *, description: Optional[str] = None + session: tc.Session, attribute: Attribute, *, description: Optional[str] = None ) -> Attribute: """Update an existing attribute @@ -243,7 +241,7 @@ def update( return _from_json(attribute.url, data) -def delete(session: Session, attribute: Attribute): +def delete(session: tc.Session, attribute: Attribute): """Deletes an existing attribute Sends a deletion request to the Tamr server diff --git a/tamr_client/datasets/dataset.py b/tamr_client/datasets/dataset.py index ccd63fc5..8b95fe03 100644 --- a/tamr_client/datasets/dataset.py +++ b/tamr_client/datasets/dataset.py @@ -1,8 +1,6 @@ from dataclasses import dataclass, replace from typing import Tuple -from requests import Session - import tamr_client as tc @@ -12,7 +10,7 @@ class Dataset: key_attribute_names: Tuple[str, ...] -def attributes(session: Session, dataset: Dataset) -> Tuple["tc.Attribute", ...]: +def attributes(session: tc.Session, dataset: Dataset) -> Tuple["tc.Attribute", ...]: """Get attributes for this dataset Args: diff --git a/tamr_client/session.py b/tamr_client/session.py index f096e63e..aa60b135 100644 --- a/tamr_client/session.py +++ b/tamr_client/session.py @@ -1,7 +1,9 @@ import requests +Session = requests.Session -def session(auth: requests.auth.HTTPBasicAuth, **kwargs) -> requests.Session: + +def from_auth(auth: requests.auth.HTTPBasicAuth, **kwargs) -> Session: """Create a new authenticated session Args: diff --git a/tests/attributes/test_attribute.py b/tests/attributes/test_attribute.py index c5a80bda..eb852574 100644 --- a/tests/attributes/test_attribute.py +++ b/tests/attributes/test_attribute.py @@ -63,8 +63,7 @@ def test_create(): @responses.activate def test_update(): - auth = tc.UsernamePasswordAuth("username", "password") - s = tc.session(auth) + s = utils.session() url = tc.URL(path="datasets/1/attributes/RowNum") attr_json = utils.load_json("attributes.json")[0] @@ -81,8 +80,7 @@ def test_update(): @responses.activate def test_delete(): - auth = tc.UsernamePasswordAuth("username", "password") - s = tc.session(auth) + s = utils.session() url = tc.URL(path="datasets/1/attributes/RowNum") attr_json = utils.load_json("attributes.json")[0] diff --git a/tests/utils.py b/tests/utils.py index 09d34362..f19aaec2 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -14,7 +14,7 @@ def load_json(path: Union[str, Path]): def session(): auth = tc.UsernamePasswordAuth("username", "password") - s = tc.session(auth) + s = tc.session.from_auth(auth) return s From 402c142f44e0e15f64458022f9b5c432a469cbe9 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 13 Feb 2020 13:52:26 -0500 Subject: [PATCH 253/632] Rename json_dict module to types --- tamr_client/attributes/attribute.py | 2 +- tamr_client/attributes/attribute_type.py | 2 +- tamr_client/attributes/subattribute.py | 2 +- tamr_client/{json_dict.py => types.py} | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename tamr_client/{json_dict.py => types.py} (100%) diff --git a/tamr_client/attributes/attribute.py b/tamr_client/attributes/attribute.py index 31673156..a5696927 100644 --- a/tamr_client/attributes/attribute.py +++ b/tamr_client/attributes/attribute.py @@ -3,7 +3,7 @@ from typing import Optional import tamr_client as tc -from tamr_client.json_dict import JsonDict +from tamr_client.types import JsonDict _RESERVED_NAMES = frozenset( [ diff --git a/tamr_client/attributes/attribute_type.py b/tamr_client/attributes/attribute_type.py index a035ed7d..11db1e37 100644 --- a/tamr_client/attributes/attribute_type.py +++ b/tamr_client/attributes/attribute_type.py @@ -6,7 +6,7 @@ from typing import ClassVar, Tuple, Union import tamr_client as tc -from tamr_client.json_dict import JsonDict +from tamr_client.types import JsonDict logger = logging.getLogger(__name__) diff --git a/tamr_client/attributes/subattribute.py b/tamr_client/attributes/subattribute.py index 6b4c9586..8b566ea4 100644 --- a/tamr_client/attributes/subattribute.py +++ b/tamr_client/attributes/subattribute.py @@ -3,7 +3,7 @@ from typing import Optional import tamr_client as tc -from tamr_client.json_dict import JsonDict +from tamr_client.types import JsonDict @dataclass(frozen=True) diff --git a/tamr_client/json_dict.py b/tamr_client/types.py similarity index 100% rename from tamr_client/json_dict.py rename to tamr_client/types.py index 73440d49..90427f37 100644 --- a/tamr_client/json_dict.py +++ b/tamr_client/types.py @@ -1,4 +1,4 @@ -# taken from https://github.com/python/typing/issues/182 from typing import Any, Dict +# taken from https://github.com/python/typing/issues/182 JsonDict = Dict[str, Any] From 0625c94000d0b97b30da37a51f41b202b882842e Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Tue, 4 Feb 2020 16:29:08 -0500 Subject: [PATCH 254/632] Changed all the .rst files in docs to .md Hopefully I did that right. --- docs/user-guide/geo.md | 86 ++++++++++++++++++ docs/user-guide/geo.rst | 117 ------------------------- docs/user-guide/installation.md | 62 +++++++++++++ docs/user-guide/installation.rst | 59 ------------- docs/user-guide/quickstart.md | 76 ++++++++++++++++ docs/user-guide/quickstart.rst | 81 ----------------- docs/user-guide/secure-credentials.md | 53 +++++++++++ docs/user-guide/secure-credentials.rst | 62 ------------- docs/user-guide/spec.md | 83 ++++++++++++++++++ docs/user-guide/spec.rst | 99 --------------------- docs/user-guide/workflows.md | 69 +++++++++++++++ docs/user-guide/workflows.rst | 73 --------------- 12 files changed, 429 insertions(+), 491 deletions(-) create mode 100644 docs/user-guide/geo.md delete mode 100644 docs/user-guide/geo.rst create mode 100644 docs/user-guide/installation.md delete mode 100644 docs/user-guide/installation.rst create mode 100644 docs/user-guide/quickstart.md delete mode 100644 docs/user-guide/quickstart.rst create mode 100644 docs/user-guide/secure-credentials.md delete mode 100644 docs/user-guide/secure-credentials.rst create mode 100644 docs/user-guide/spec.md delete mode 100644 docs/user-guide/spec.rst create mode 100644 docs/user-guide/workflows.md delete mode 100644 docs/user-guide/workflows.rst diff --git a/docs/user-guide/geo.md b/docs/user-guide/geo.md new file mode 100644 index 00000000..6ef2e1bb --- /dev/null +++ b/docs/user-guide/geo.md @@ -0,0 +1,86 @@ +Geospatial Data +=============== +What geospatial data is supported? +---------------------------------- + +In general, the Python Geo Interface is supported; see https://gist.github.com/sgillies/2217756 + +There are three layers of information, modeled after GeoJSON; see https://tools.ietf.org/html/rfc7946 + +* The outermost layer is a FeatureCollection +* Within a FeatureCollection are Features, each of which represents one "thing", like a building or a river. Each feature has: + * type (string; required) + * id (object; required) + * geometry (Geometry, see below; optional) + * bbox ("bounding box", 4 doubles; optional) + * properties (map[string, object]; optional) +* Within a Feature is a Geometry, which represents a shape, like a point or a polygon. Each geometry has: + * type (one of "Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon"; required) + * coordinates (doubles; exactly how these are structured depends on the type of the geometry) + +Although the Python Geo Interface is non-prescriptive when it comes to the data types of the id and properties, Tamr has a more restricted set of supported types. See https://docs.tamr.com/reference#attribute-types + +The ``:class:~tamr_unify_client.models.dataset.resource.Dataset`` class supports the ``__geo_interface__`` property. This will produce one ``FeatureCollection`` for the entire dataset. + +There is a companion iterator ``itergeofeatures()`` that returns a generator that allows you to +stream the records in the dataset as Geospatial features. + +To produce a GeoJSON representation of a dataset: +``` +dataset = client.datasets.by_name("my_dataset") +with open("my_dataset.json", "w") as f: + json.dump(dataset.__geo_interface__, f) +``` +By default, ``itergeofeatures()`` will use the first dataset attribute with geometry type to fill in the feature geometry. You can override this by specifying the geometry attribute to use in the ``geo_attr`` parameter to ``itergeofeatures``. + +``Dataset`` can also be updated from a feature collection that supports the Python Geo Interface: +``` +import geopandas +geodataframe = geopandas.GeoDataFrame(...) +dataset = client.dataset.by_name("my_dataset") +dataset.from_geo_features(geodataframe) +``` +By default the features' geometries will be placed into the first dataset attribute with geometry +type. You can override this by specifying the geometry attribute to use in the ``geo_attr`` +parameter to ``from_geo_features``. + +Rules for converting from Tamr records to Geospatial Features +------------------------------------------------------------------ + +The record's primary key will be used as the feature's ``id``. If the primary key is a single attribute, then the value of that attribute will be the value of ``id``. If the primary key is composed of multiple attributes, then the value of the ``id`` will be an array with the values of the key attributes in order. + +Tamr allows any number of geometry attributes per record; the Python Geo Interface is limited to one. When converting Tamr records to Python Geo Features, the first geometry attribute in the schema will be used as the geometry; all other geometry attributes will appear as properties with no type conversion. In the future, additional control over the handling of multiple geometries may be provided; the current set of capabilities is intended primarily to support the use case of working with FeatureCollections within Tamr, and FeatureCollection has only one geometry per feature. + +An attribute is considered to have geometry type if it has type ``RECORD`` and contains an attribute named ``point``, ``multiPoint``, ``lineString``, ``multiLineString``, ``polygon``, or ``multiPolygon``. + +If an attribute named ``bbox`` is available, it will be used as ``bbox``. No conversion is done on the value of ``bbox``. In the future, additional control over the handling of ``bbox`` attributes may be provided. + +All other attributes will be placed in ``properties``, with no type conversion. This includes all geometry attributes other than the first. + +Rules for converting from Geospatial Features to Tamr records +-------------------------------------------------------------- + +The Feature's ``id`` will be converted into the primary key for the record. If the record uses a simple key, no value translation will be done. If the record uses a composite key, then the value of the Feature's ``id`` must be an array of values, one per attribute in the key. + +If the Feature contains keys in ``properties`` that conflict with the record keys, ``bbox``, or geometry, those keys are ignored (omitted). + +If the Feature contains a ``bbox``, it is copied to the record's ``bbox``. + +All other keys in the Feature's ``properties`` are propagated to the same-name attribute on the record, with no type conversion. + +Streaming data access +--------------------- + +The ``Dataset`` method ``itergeofeatures()`` returns a generator that allows you to stream the records in the dataset as Geospatial features: +``` +my_dataset = client.datasets.by_name("my_dataset") +for feature in my_dataset.itergeofeatures(): + do_something(feature) +``` +Note that many packages that consume the Python Geo Interface will be able to consume this +iterator directly. For example:: +``` +from geopandas import GeoDataFrame +df = GeoDataFrame.from_features(my_dataset.itergeofeatures()) +``` +This allows construction of a GeoDataFrame directly from the stream of records, without materializing the intermediate dataset. diff --git a/docs/user-guide/geo.rst b/docs/user-guide/geo.rst deleted file mode 100644 index 59463c27..00000000 --- a/docs/user-guide/geo.rst +++ /dev/null @@ -1,117 +0,0 @@ -Geospatial Data -=============== - -What geospatial data is supported? ----------------------------------- - -In general, the Python Geo Interface is supported; see https://gist.github.com/sgillies/2217756 - -There are three layers of information, modeled after GeoJSON; see https://tools.ietf.org/html/rfc7946 : - -- The outermost layer is a FeatureCollection -- Within a FeatureCollection are Features, each of which represents one "thing", like a building - or a river. Each feature has: - - - type (string; required) - - id (object; required) - - geometry (Geometry, see below; optional) - - bbox ("bounding box", 4 doubles; optional) - - properties (map[string, object]; optional) - -- Within a Feature is a Geometry, which represents a shape, like a point or a polygon. Each - geometry has: - - - type (one of "Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon"; - required) - - coordinates (doubles; exactly how these are structured depends on the type of the geometry) - -Although the Python Geo Interface is non-prescriptive when it comes to the data types of the id and -properties, Tamr has a more restricted set of supported types. See https://docs.tamr.com/reference#attribute-types - -The :class:`~tamr_unify_client.models.dataset.resource.Dataset` class supports the -``__geo_interface__`` property. This will produce one ``FeatureCollection`` for the entire dataset. - -There is a companion iterator ``itergeofeatures()`` that returns a generator that allows you to -stream the records in the dataset as Geospatial features. - -To produce a GeoJSON representation of a dataset:: - - dataset = client.datasets.by_name("my_dataset") - with open("my_dataset.json", "w") as f: - json.dump(dataset.__geo_interface__, f) - - -By default, ``itergeofeatures()`` will use the first dataset attribute with geometry type to fill -in the feature geometry. You can override this by specifying the geometry attribute to use in the -``geo_attr`` parameter to ``itergeofeatures``. - -``Dataset`` can also be updated from a feature collection that supports the Python Geo Interface:: - - import geopandas - geodataframe = geopandas.GeoDataFrame(...) - dataset = client.dataset.by_name("my_dataset") - dataset.from_geo_features(geodataframe) - -By default the features' geometries will be placed into the first dataset attribute with geometry -type. You can override this by specifying the geometry attribute to use in the ``geo_attr`` -parameter to ``from_geo_features``. - -Rules for converting from Tamr records to Geospatial Features ------------------------------------------------------------------- - -The record's primary key will be used as the feature's ``id``. If the primary key is a single -attribute, then the value of that attribute will be the value of ``id``. If the primary key is -composed of multiple attributes, then the value of the ``id`` will be an array with the values -of the key attributes in order. - -Tamr allows any number of geometry attributes per record; the Python Geo Interface is limited to -one. When converting Tamr records to Python Geo Features, the first geometry attribute in the schema -will be used as the geometry; all other geometry attributes will appear as properties with no type -conversion. In the future, additional control over the handling of multiple geometries may be -provided; the current set of capabilities is intended primarily to support the use case of working -with FeatureCollections within Tamr, and FeatureCollection has only one geometry per feature. - -An attribute is considered to have geometry type if it has type ``RECORD`` and contains an attribute -named ``point``, ``multiPoint``, ``lineString``, ``multiLineString``, ``polygon``, or -``multiPolygon``. - -If an attribute named ``bbox`` is available, it will be used as ``bbox``. No conversion is done -on the value of ``bbox``. In the future, additional control over the handling of ``bbox`` attributes -may be provided. - -All other attributes will be placed in ``properties``, with no type conversion. This includes -all geometry attributes other than the first. - -Rules for converting from Geospatial Features to Tamr records --------------------------------------------------------------- - -The Feature's ``id`` will be converted into the primary key for the record. If the record uses -a simple key, no value translation will be done. If the record uses a composite key, then the -value of the Feature's ``id`` must be an array of values, one per attribute in the key. - -If the Feature contains keys in ``properties`` that conflict with the record keys, ``bbox``, -or geometry, those keys are ignored (omitted). - -If the Feature contains a ``bbox``, it is copied to the record's ``bbox``. - -All other keys in the Feature's ``properties`` are propagated to the same-name attribute on the -record, with no type conversion. - -Streaming data access ---------------------- - -The ``Dataset`` method ``itergeofeatures()`` returns a generator that allows you to -stream the records in the dataset as Geospatial features:: - - my_dataset = client.datasets.by_name("my_dataset") - for feature in my_dataset.itergeofeatures(): - do_something(feature) - -Note that many packages that consume the Python Geo Interface will be able to consume this -iterator directly. For example:: - - from geopandas import GeoDataFrame - df = GeoDataFrame.from_features(my_dataset.itergeofeatures()) - -This allows construction of a GeoDataFrame directly from the stream of records, without -materializing the intermediate dataset. diff --git a/docs/user-guide/installation.md b/docs/user-guide/installation.md new file mode 100644 index 00000000..fc45b4cf --- /dev/null +++ b/docs/user-guide/installation.md @@ -0,0 +1,62 @@ +Installation +============ + +``tamr-unify-client`` is compatible with Python 3.6 or newer. + +Stable releases +--------------- +Installation is as simple as: + +``pip install tamr-unify-client`` + +Or: + +``poetry add tamr-unify-client`` + +Note: + +If you don't use [poetry](https://poetry.eustace.io/), we recommend you use a virtual environment for your project and install the Python Client into that virtual environment. + +You can create a virtual environment with Python 3 via: + +``python3 -m venv my-venv`` + + For more, see [The Hitchhiker's Guide to Python](https://docs.python-guide.org/dev/virtualenvs/). + +Latest (unstable) +----------------- +Note: + +This project uses the new ``pyproject.toml`` file, not a ``setup.py`` file, so make sure you have the latest version of ``pip`` installed: ``pip install -U pip``. + +To install the bleeding edge: +``` +git clone https://github.com/Datatamer/tamr-client +cd tamr-client +pip install . +``` + +Offline installs +---------------- + +First, download ``tamr-unify-client`` and its dependencies on a machine with online access to PyPI: + +``` +pip download tamr-unify-client -d tamr-unify-client-requirements +zip -r tamr-unify-client-requirements.zip tamr-unify-client-requirements +``` + +Then, ship the ``.zip`` file to the target machine where you want ``tamr-unify-client`` installed. You can do this via email, cloud drives, ``scp`` or any other mechanism. + +Finally, install ``tamr-unify-client`` from the saved dependencies: + +``` +unzip tamr-unify-client-requirements.zip +pip install --no-index --find-links=tamr-unify-client-requirements tamr-unify-client +``` + +If you are not using a virtual environment, you may need to specify the ``--user`` flag if you get permissions errors: + +``` +pip install --user --no-index --find-links=tamr-unify-client-requirements tamr-unify-client +``` diff --git a/docs/user-guide/installation.rst b/docs/user-guide/installation.rst deleted file mode 100644 index e6b01e11..00000000 --- a/docs/user-guide/installation.rst +++ /dev/null @@ -1,59 +0,0 @@ -Installation -============ - -``tamr-unify-client`` is compatible with Python 3.6 or newer. - -Stable releases ---------------- - -Installation is as simple as:: - - pip install tamr-unify-client - -Or:: - - poetry add tamr-unify-client - -.. note:: - If you don't use `poetry `_, we recommend you use a virtual environment for - your project and install the Python Client into that virtual environment. - - You can create a virtual environment with Python 3 via:: - - python3 -m venv my-venv - - For more, see `The Hitchhiker's Guide to Python `_ . - -Latest (unstable) ------------------ - -.. note:: - This project uses the new ``pyproject.toml`` file, not a ``setup.py`` file, so - make sure you have the latest version of ``pip`` installed: ``pip install -U pip``. - -To install the bleeding edge:: - - git clone https://github.com/Datatamer/tamr-client - cd tamr-client - pip install . - -Offline installs ----------------- - -First, download ``tamr-unify-client`` and its dependencies on a machine with online access to PyPI:: - - pip download tamr-unify-client -d tamr-unify-client-requirements - zip -r tamr-unify-client-requirements.zip tamr-unify-client-requirements - -Then, ship the ``.zip`` file to the target machine where you want ``tamr-unify-client`` installed. -You can do this via email, cloud drives, ``scp`` or any other mechanism. - -Finally, install ``tamr-unify-client`` from the saved dependencies:: - - unzip tamr-unify-client-requirements.zip - pip install --no-index --find-links=tamr-unify-client-requirements tamr-unify-client - -If you are not using a virtual environment, you may need to specify the ``--user`` flag -if you get permissions errors:: - - pip install --user --no-index --find-links=tamr-unify-client-requirements tamr-unify-client diff --git a/docs/user-guide/quickstart.md b/docs/user-guide/quickstart.md new file mode 100644 index 00000000..58c15507 --- /dev/null +++ b/docs/user-guide/quickstart.md @@ -0,0 +1,76 @@ +Quickstart +========== + +Client configuration +-------------------- + +Start by importing the Python Client and authentication provider: +``` +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth +``` +Next, create an authentication provider and use that to create an authenticated client: +``` +import os + +username = os.environ['TAMR_USERNAME'] +password = os.environ['TAMR_PASSWORD'] + +auth = UsernamePasswordAuth(username, password) +tamr = Client(auth) +``` +Warning: + +For security, it's best to read your credentials in from environment variables or secure files instead of hardcoding them directly into your code. + +For more, see [User Guide > Secure Credentials](secure-credentials.html). + +By default, the client tries to find the Tamr instance on ``localhost``. To point to a different host, set the host argument when instantiating the Client. + +For example, to connect to ``10.20.0.1``: +``` +tamr = Client(auth, host='10.20.0.1') +``` +Top-level collections +--------------------- +The Python Client exposes 2 top-level collections: Projects and Datasets. + +You can access these collections through the client and loop over their members +with simple ``for``-loops. + +E.g.: +``` +for project in tamr.projects: + print(project.name) + +for dataset in tamr.datasets: + print(dataset.name) +``` +Fetch a specific resource +------------------------- +If you know the identifier for a specific resource, you can ask for it directly via the ``by_resource_id`` methods exposed by collections. + +E.g. To fetch the project with ID ``'1'``: +``` +project = tamr.projects.by_resource_id('1') +``` +Resource relationships +---------------------- + +Related resources (like a project and its unified dataset) can be accessed through specific methods. + +E.g. To access the Unified Dataset for a particular project: +``` +ud = project.unified_dataset() +``` +Kick-off Tamr Operations +------------------------- + +Some methods on Model objects can kick-off long-running Tamr operations. + +Here, kick-off a "Unified Dataset refresh" operation: +``` +operation = project.unified_dataset().refresh() +assert op.succeeded() +``` +By default, the API Clients expose a synchronous interface for Tamr operations. diff --git a/docs/user-guide/quickstart.rst b/docs/user-guide/quickstart.rst deleted file mode 100644 index 582e51b0..00000000 --- a/docs/user-guide/quickstart.rst +++ /dev/null @@ -1,81 +0,0 @@ -Quickstart -========== - -Client configuration --------------------- - -Start by importing the Python Client and authentication provider:: - - from tamr_unify_client import Client - from tamr_unify_client.auth import UsernamePasswordAuth - -Next, create an authentication provider and use that to create an authenticated client:: - - import os - - username = os.environ['TAMR_USERNAME'] - password = os.environ['TAMR_PASSWORD'] - - auth = UsernamePasswordAuth(username, password) - tamr = Client(auth) - -.. warning:: - For security, it's best to read your credentials in from environment variables - or secure files instead of hardcoding them directly into your code. - - For more, see `User Guide > Secure Credentials `_ . - -By default, the client tries to find the Tamr instance on ``localhost``. -To point to a different host, set the host argument when instantiating the Client. - -For example, to connect to ``10.20.0.1``:: - - tamr = Client(auth, host='10.20.0.1') - -Top-level collections ---------------------- - -The Python Client exposes 2 top-level collections: Projects and Datasets. - -You can access these collections through the client and loop over their members -with simple ``for``-loops. - -E.g.:: - - for project in tamr.projects: - print(project.name) - - for dataset in tamr.datasets: - print(dataset.name) - -Fetch a specific resource -------------------------- - -If you know the identifier for a specific resource, you can ask for it directly -via the ``by_resource_id`` methods exposed by collections. - -E.g. To fetch the project with ID ``'1'``:: - - project = tamr.projects.by_resource_id('1') - -Resource relationships ----------------------- - -Related resources (like a project and its unified dataset) can be accessed -through specific methods. - -E.g. To access the Unified Dataset for a particular project:: - - ud = project.unified_dataset() - -Kick-off Tamr Operations -------------------------- - -Some methods on Model objects can kick-off long-running Tamr operations. - -Here, kick-off a "Unified Dataset refresh" operation:: - - operation = project.unified_dataset().refresh() - assert op.succeeded() - -By default, the API Clients expose a synchronous interface for Tamr operations. diff --git a/docs/user-guide/secure-credentials.md b/docs/user-guide/secure-credentials.md new file mode 100644 index 00000000..cb4fa038 --- /dev/null +++ b/docs/user-guide/secure-credentials.md @@ -0,0 +1,53 @@ +Secure Credentials +================== + +This section discusses ways to pass credentials securely to +`:class:~tamr_unify_client.auth.UsernamePasswordAuth`. Specifically, you **should not** hardcode your password(s) in your source code. Instead, you should use environment variables or secure files to store your credentials and simple Python code to read your credentials. + +Environment variables +--------------------- +You can use ``os.environ`` to read in your credentials from environment variables: +``` +# my_script.py +import os + +from tamr_unify_client.auth import UsernamePasswordAuth + +username = os.environ['TAMR_USERNAME'] # replace with your username environment variable name +password = os.environ['TAMR_PASSWORD'] # replace with your password environment variable name + +auth = UsernamePasswordAuth(username, password) +``` + +You can pass in the environment variables from the terminal by including them before your command: +``` +TAMR_USERNAME="my Tamr username" TAMR_PASSWORD="my Tamr password" python my_script.py +``` +You can also create an ``.sh`` file to store your environment variables and +simply ``source`` that file before running your script. + + +Config files +------------ +You can also store your credentials in a secure credentials file: +``` +# credentials.yaml +--- +username: "my tamr username" +password: "my tamr password" +``` +Then ``pip install pyyaml`` read the credentials in your Python code: +``` +# my_script.py +from tamr_unify_client.auth import UsernamePasswordAuth +import yaml + +with open("path/to/credentials.yaml") as f: # replace with your credentials.yaml path + creds = yaml.safe_load(f) + +auth = UsernamePasswordAuth(creds['username'], creds['password']) +``` +As in this example, we recommend you use YAML as your format since YAML has support for comments and is more human-readable than JSON. + +Important: +You **should not** check these credentials files into your version control system (e.g. ``git``). Do not share this file with anyone who should not have access to the password stored in it. diff --git a/docs/user-guide/secure-credentials.rst b/docs/user-guide/secure-credentials.rst deleted file mode 100644 index 937b991f..00000000 --- a/docs/user-guide/secure-credentials.rst +++ /dev/null @@ -1,62 +0,0 @@ -Secure Credentials -================== - -This section discusses ways to pass credentials securely to -:class:`~tamr_unify_client.auth.UsernamePasswordAuth`. Specifically, you **should -not** hardcode your password(s) in your source code. Instead, you should use -environment variables or secure files to store your credentials and -simple Python code to read your credentials. - -Environment variables ---------------------- - -You can use ``os.environ`` to read in your credentials from environment variables:: - - # my_script.py - import os - - from tamr_unify_client.auth import UsernamePasswordAuth - - username = os.environ['TAMR_USERNAME'] # replace with your username environment variable name - password = os.environ['TAMR_PASSWORD'] # replace with your password environment variable name - - auth = UsernamePasswordAuth(username, password) - - -You can pass in the environment variables from the terminal by including them -before your command:: - - TAMR_USERNAME="my Tamr username" TAMR_PASSWORD="my Tamr password" python my_script.py - -You can also create an ``.sh`` file to store your environment variables and -simply ``source`` that file before running your script. - - -Config files ------------- - -You can also store your credentials in a secure credentials file:: - - # credentials.yaml - --- - username: "my tamr username" - password: "my tamr password" - -Then ``pip install pyyaml`` read the credentials in your Python code:: - - # my_script.py - from tamr_unify_client.auth import UsernamePasswordAuth - import yaml - - with open("path/to/credentials.yaml") as f: # replace with your credentials.yaml path - creds = yaml.safe_load(f) - - auth = UsernamePasswordAuth(creds['username'], creds['password']) - -As in this example, we recommend you use YAML as your format since YAML has -support for comments and is more human-readable than JSON. - -.. important:: - You **should not** check these credentials files into your version - control system (e.g. ``git``). Do not share this file with anyone who should - not have access to the password stored in it. diff --git a/docs/user-guide/spec.md b/docs/user-guide/spec.md new file mode 100644 index 00000000..2c8dd194 --- /dev/null +++ b/docs/user-guide/spec.md @@ -0,0 +1,83 @@ +Creating and Modifying Resources +================================ +Creating resources +------------------ +Resources, such as projects, dataset, and attribute configurations, can be created through their respective collections. Each ``create`` function takes in a dictionary that conforms to the +[Tamr Public Docs](https://docs.tamr.com/reference) for creating that resource type: +``` +spec = { + "name": "project", + "description": "Mastering Project", + "type": "DEDUP" + "unifiedDatasetName": "project_unified_dataset" +} +project = tamr.projects.create(spec) +``` +Using specs +----------- +These dictionaries can also be created using spec classes. + +Each ``Resource`` has a corresponding ``ResourceSpec`` which can be used to build an instance of that resource by specifying the value for each property. + +The spec can then be converted to a dictionary that can be passed to ``create``. + +For instance, to create a project: +``` +spec = ( + ProjectSpec.new() + .with_name("Project") + .with_type("DEDUP") + .with_description("Mastering Project") + .with_unified_dataset_name("Project_unified_dataset") + .with_external_id("tamrProject1") +) +project = tamr.projects.create(spec.to_dict()) +``` +Calling ``with_*`` on a spec creates a new spec with the same properties besides the modified one. The original spec is unaltered, so it could be used multiple times: +``` +base_spec = ( + ProjectSpec.new() + .with_type("DEDUP") + .with_description("Mastering Project") +) + +specs = [] +for name in project_names: + spec = ( + base_spec.with_name(name) + .with_unified_dataset_name(name + "_unified_dataset") + ) + specs.append(spec) + +projects = [tamr.projects.create(spec.to_dict()) for spec in specs] +``` +Creating a dataset +------------------ +Datasets can be created as described above, but the dataset's schema and records must then be handled separately. + +To combine all of these steps into one, ``DatasetCollection`` has a convenience function ``create_from_dataframe`` that takes a [Pandas DataFrame](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.html). +This makes it easy to create a Tamr dataset from a CSV: +``` +import pandas as pd + +df = pd.read_csv("my_data.csv") +dataset = tamr.datasets.create_from_dataframe(df, "primary key name", "My Data") +``` +This will create a dataset called "My Data" with the specified primary key, an attribute +for each column of the ``DataFrame``, and the ``DataFrame``'s rows as records. + +Modifying a resource +-------------------- +Certain resources can also be modified using specs. + +After getting a spec corresponding to a resource and modifying some properties, +the updated resource can be committed to Tamr with the ``put`` function: +``` +updated_dataset = ( + dataset.spec() + .with_description("Modified description") + .put() +) +``` +Each spec class has many properties that can be changed, but refer to the +[Public Docs](https://docs.tamr.com/reference) for which properties will actually be updated in Tamr. If an immutable property is changed in the update request, the new value will simply be ignored. diff --git a/docs/user-guide/spec.rst b/docs/user-guide/spec.rst deleted file mode 100644 index f232604a..00000000 --- a/docs/user-guide/spec.rst +++ /dev/null @@ -1,99 +0,0 @@ -Creating and Modifying Resources -================================ - -Creating resources ------------------- - -Resources, such as projects, dataset, and attribute configurations, -can be created through their respective collections. -Each ``create`` function takes in a dictionary that conforms to the -`Tamr Public Docs `_ for creating that resource type:: - - spec = { - "name": "project", - "description": "Mastering Project", - "type": "DEDUP" - "unifiedDatasetName": "project_unified_dataset" - } - project = tamr.projects.create(spec) - -Using specs ------------ - -These dictionaries can also be created using spec classes. - -Each ``Resource`` has a corresponding ``ResourceSpec`` which can be used to build an -instance of that resource by specifying the value for each property. - -The spec can then be converted to a dictionary that can be passed to ``create``. - -For instance, to create a project:: - - spec = ( - ProjectSpec.new() - .with_name("Project") - .with_type("DEDUP") - .with_description("Mastering Project") - .with_unified_dataset_name("Project_unified_dataset") - .with_external_id("tamrProject1") - ) - project = tamr.projects.create(spec.to_dict()) - - -Calling ``with_*`` on a spec creates a new spec with the same properties besides the -modified one. The original spec is unaltered, so it could be used multiple times:: - - base_spec = ( - ProjectSpec.new() - .with_type("DEDUP") - .with_description("Mastering Project") - ) - - specs = [] - for name in project_names: - spec = ( - base_spec.with_name(name) - .with_unified_dataset_name(name + "_unified_dataset") - ) - specs.append(spec) - - projects = [tamr.projects.create(spec.to_dict()) for spec in specs] - - -Creating a dataset ------------------- - -Datasets can be created as described above, but the dataset's schema and -records must then be handled separately. - -To combine all of these steps into one, ``DatasetCollection`` has a convenience -function ``create_from_dataframe`` that takes a -`Pandas DataFrame `_. -This makes it easy to create a Tamr dataset from a CSV:: - - import pandas as pd - - df = pd.read_csv("my_data.csv") - dataset = tamr.datasets.create_from_dataframe(df, "primary key name", "My Data") - - -This will create a dataset called "My Data" with the specified primary key, an attribute -for each column of the ``DataFrame``, and the ``DataFrame``'s rows as records. - -Modifying a resource --------------------- - -Certain resources can also be modified using specs. - -After getting a spec corresponding to a resource and modifying some properties, -the updated resource can be committed to Tamr with the ``put`` function:: - - updated_dataset = ( - dataset.spec() - .with_description("Modified description") - .put() - ) - -Each spec class has many properties that can be changed, but refer to the -`Public Docs `_ for which properties will actually be updated in Tamr. -If an immutable property is changed in the update request, the new value will simply be ignored. diff --git a/docs/user-guide/workflows.md b/docs/user-guide/workflows.md new file mode 100644 index 00000000..7caa94d1 --- /dev/null +++ b/docs/user-guide/workflows.md @@ -0,0 +1,69 @@ +Workflows +========= +Continuous Categorization +------------------------- +``` +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth +import os + +username = os.environ['TAMR_USERNAME'] +password = os.environ['TAMR_PASSWORD'] +auth = UsernamePasswordAuth(username, password) + +host = 'localhost' # replace with your host +tamr = Client(auth) + +project_id = "1" # replace with your project ID +project = tamr.projects.by_resource_id(project_id) +project = project.as_categorization() + +unified_dataset = project.unified_dataset() +op = unified_dataset.refresh() +assert op.succeeded() + +model = project.model() +op = model.train() +assert op.succeeded() + +op = model.predict() +assert op.succeeded() +``` +Continuous Mastering +-------------------- +``` +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth +import os + +username = os.environ['TAMR_USERNAME'] +password = os.environ['TAMR_PASSWORD'] +auth = UsernamePasswordAuth(username, password) + +host = 'localhost' # replace with your host +tamr = Client(auth) + +project_id = "1" # replace with your project ID +project = tamr.projects.by_resource_id(project_id) +project = project.as_mastering() + +unified_dataset = project.unified_dataset() +op = unified_dataset.refresh() +assert op.succeeded() + +op = project.pairs().refresh() +assert op.succeeded() + +model = project.pair_matching_model() +op = model.train() +assert op.succeeded() + +op = model.predict() +assert op.succeeded() + +op = project.record_clusters().refresh() +assert op.succeeded() + +op = project.published_clusters().refresh() +assert op.succeeded() +``` diff --git a/docs/user-guide/workflows.rst b/docs/user-guide/workflows.rst deleted file mode 100644 index 82190840..00000000 --- a/docs/user-guide/workflows.rst +++ /dev/null @@ -1,73 +0,0 @@ -Workflows -========= - -Continuous Categorization -------------------------- - -:: - - from tamr_unify_client import Client - from tamr_unify_client.auth import UsernamePasswordAuth - import os - - username = os.environ['TAMR_USERNAME'] - password = os.environ['TAMR_PASSWORD'] - auth = UsernamePasswordAuth(username, password) - - host = 'localhost' # replace with your host - tamr = Client(auth) - - project_id = "1" # replace with your project ID - project = tamr.projects.by_resource_id(project_id) - project = project.as_categorization() - - unified_dataset = project.unified_dataset() - op = unified_dataset.refresh() - assert op.succeeded() - - model = project.model() - op = model.train() - assert op.succeeded() - - op = model.predict() - assert op.succeeded() - -Continuous Mastering --------------------- - -:: - - from tamr_unify_client import Client - from tamr_unify_client.auth import UsernamePasswordAuth - import os - - username = os.environ['TAMR_USERNAME'] - password = os.environ['TAMR_PASSWORD'] - auth = UsernamePasswordAuth(username, password) - - host = 'localhost' # replace with your host - tamr = Client(auth) - - project_id = "1" # replace with your project ID - project = tamr.projects.by_resource_id(project_id) - project = project.as_mastering() - - unified_dataset = project.unified_dataset() - op = unified_dataset.refresh() - assert op.succeeded() - - op = project.pairs().refresh() - assert op.succeeded() - - model = project.pair_matching_model() - op = model.train() - assert op.succeeded() - - op = model.predict() - assert op.succeeded() - - op = project.record_clusters().refresh() - assert op.succeeded() - - op = project.published_clusters().refresh() - assert op.succeeded() From 88ae9ed2265adbcc5c380cef31914f0fde92d814 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Wed, 5 Feb 2020 16:27:38 -0500 Subject: [PATCH 255/632] Changed the headers from ===/--- to #/## --- docs/user-guide/geo.md | 19 ++++++------------- docs/user-guide/installation.md | 12 ++++-------- docs/user-guide/quickstart.md | 21 ++++++--------------- docs/user-guide/secure-credentials.md | 10 +++------- docs/user-guide/spec.md | 15 +++++---------- docs/user-guide/workflows.md | 9 +++------ 6 files changed, 27 insertions(+), 59 deletions(-) diff --git a/docs/user-guide/geo.md b/docs/user-guide/geo.md index 6ef2e1bb..fb0c27a6 100644 --- a/docs/user-guide/geo.md +++ b/docs/user-guide/geo.md @@ -1,8 +1,5 @@ -Geospatial Data -=============== -What geospatial data is supported? ----------------------------------- - +# Geospatial Data +## What geospatial data is supported? In general, the Python Geo Interface is supported; see https://gist.github.com/sgillies/2217756 There are three layers of information, modeled after GeoJSON; see https://tools.ietf.org/html/rfc7946 @@ -44,8 +41,8 @@ By default the features' geometries will be placed into the first dataset attrib type. You can override this by specifying the geometry attribute to use in the ``geo_attr`` parameter to ``from_geo_features``. -Rules for converting from Tamr records to Geospatial Features ------------------------------------------------------------------- +## Rules for converting from Tamr records to Geospatial Features + The record's primary key will be used as the feature's ``id``. If the primary key is a single attribute, then the value of that attribute will be the value of ``id``. If the primary key is composed of multiple attributes, then the value of the ``id`` will be an array with the values of the key attributes in order. @@ -57,9 +54,7 @@ If an attribute named ``bbox`` is available, it will be used as ``bbox``. No con All other attributes will be placed in ``properties``, with no type conversion. This includes all geometry attributes other than the first. -Rules for converting from Geospatial Features to Tamr records --------------------------------------------------------------- - +## Rules for converting from Geospatial Features to Tamr records The Feature's ``id`` will be converted into the primary key for the record. If the record uses a simple key, no value translation will be done. If the record uses a composite key, then the value of the Feature's ``id`` must be an array of values, one per attribute in the key. If the Feature contains keys in ``properties`` that conflict with the record keys, ``bbox``, or geometry, those keys are ignored (omitted). @@ -68,9 +63,7 @@ If the Feature contains a ``bbox``, it is copied to the record's ``bbox``. All other keys in the Feature's ``properties`` are propagated to the same-name attribute on the record, with no type conversion. -Streaming data access ---------------------- - +## Streaming data access The ``Dataset`` method ``itergeofeatures()`` returns a generator that allows you to stream the records in the dataset as Geospatial features: ``` my_dataset = client.datasets.by_name("my_dataset") diff --git a/docs/user-guide/installation.md b/docs/user-guide/installation.md index fc45b4cf..4ee59bc9 100644 --- a/docs/user-guide/installation.md +++ b/docs/user-guide/installation.md @@ -1,10 +1,8 @@ -Installation -============ +# Installation ``tamr-unify-client`` is compatible with Python 3.6 or newer. -Stable releases ---------------- +## Stable releases Installation is as simple as: ``pip install tamr-unify-client`` @@ -23,8 +21,7 @@ You can create a virtual environment with Python 3 via: For more, see [The Hitchhiker's Guide to Python](https://docs.python-guide.org/dev/virtualenvs/). -Latest (unstable) ------------------ +## Latest (unstable) Note: This project uses the new ``pyproject.toml`` file, not a ``setup.py`` file, so make sure you have the latest version of ``pip`` installed: ``pip install -U pip``. @@ -36,8 +33,7 @@ cd tamr-client pip install . ``` -Offline installs ----------------- +## Offline installs First, download ``tamr-unify-client`` and its dependencies on a machine with online access to PyPI: diff --git a/docs/user-guide/quickstart.md b/docs/user-guide/quickstart.md index 58c15507..812aab9f 100644 --- a/docs/user-guide/quickstart.md +++ b/docs/user-guide/quickstart.md @@ -1,8 +1,5 @@ -Quickstart -========== - -Client configuration --------------------- +# Quickstart +## Client configuration Start by importing the Python Client and authentication provider: ``` @@ -31,8 +28,7 @@ For example, to connect to ``10.20.0.1``: ``` tamr = Client(auth, host='10.20.0.1') ``` -Top-level collections ---------------------- +## Top-level collections The Python Client exposes 2 top-level collections: Projects and Datasets. You can access these collections through the client and loop over their members @@ -46,26 +42,21 @@ for project in tamr.projects: for dataset in tamr.datasets: print(dataset.name) ``` -Fetch a specific resource -------------------------- +## Fetch a specific resource If you know the identifier for a specific resource, you can ask for it directly via the ``by_resource_id`` methods exposed by collections. E.g. To fetch the project with ID ``'1'``: ``` project = tamr.projects.by_resource_id('1') ``` -Resource relationships ----------------------- - +## Resource relationships Related resources (like a project and its unified dataset) can be accessed through specific methods. E.g. To access the Unified Dataset for a particular project: ``` ud = project.unified_dataset() ``` -Kick-off Tamr Operations -------------------------- - +## Kick-off Tamr Operations Some methods on Model objects can kick-off long-running Tamr operations. Here, kick-off a "Unified Dataset refresh" operation: diff --git a/docs/user-guide/secure-credentials.md b/docs/user-guide/secure-credentials.md index cb4fa038..d2ae6397 100644 --- a/docs/user-guide/secure-credentials.md +++ b/docs/user-guide/secure-credentials.md @@ -1,11 +1,8 @@ -Secure Credentials -================== - +# Secure Credentials This section discusses ways to pass credentials securely to `:class:~tamr_unify_client.auth.UsernamePasswordAuth`. Specifically, you **should not** hardcode your password(s) in your source code. Instead, you should use environment variables or secure files to store your credentials and simple Python code to read your credentials. -Environment variables ---------------------- +## Environment variables You can use ``os.environ`` to read in your credentials from environment variables: ``` # my_script.py @@ -27,8 +24,7 @@ You can also create an ``.sh`` file to store your environment variables and simply ``source`` that file before running your script. -Config files ------------- +## Config files You can also store your credentials in a secure credentials file: ``` # credentials.yaml diff --git a/docs/user-guide/spec.md b/docs/user-guide/spec.md index 2c8dd194..25c001c1 100644 --- a/docs/user-guide/spec.md +++ b/docs/user-guide/spec.md @@ -1,7 +1,5 @@ -Creating and Modifying Resources -================================ -Creating resources ------------------- +# Creating and Modifying Resources +## Creating resources Resources, such as projects, dataset, and attribute configurations, can be created through their respective collections. Each ``create`` function takes in a dictionary that conforms to the [Tamr Public Docs](https://docs.tamr.com/reference) for creating that resource type: ``` @@ -13,8 +11,7 @@ spec = { } project = tamr.projects.create(spec) ``` -Using specs ------------ +## Using specs These dictionaries can also be created using spec classes. Each ``Resource`` has a corresponding ``ResourceSpec`` which can be used to build an instance of that resource by specifying the value for each property. @@ -51,8 +48,7 @@ for name in project_names: projects = [tamr.projects.create(spec.to_dict()) for spec in specs] ``` -Creating a dataset ------------------- +## Creating a dataset Datasets can be created as described above, but the dataset's schema and records must then be handled separately. To combine all of these steps into one, ``DatasetCollection`` has a convenience function ``create_from_dataframe`` that takes a [Pandas DataFrame](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.html). @@ -66,8 +62,7 @@ dataset = tamr.datasets.create_from_dataframe(df, "primary key name", "My Data") This will create a dataset called "My Data" with the specified primary key, an attribute for each column of the ``DataFrame``, and the ``DataFrame``'s rows as records. -Modifying a resource --------------------- +## Modifying a resource Certain resources can also be modified using specs. After getting a spec corresponding to a resource and modifying some properties, diff --git a/docs/user-guide/workflows.md b/docs/user-guide/workflows.md index 7caa94d1..de7ca1d0 100644 --- a/docs/user-guide/workflows.md +++ b/docs/user-guide/workflows.md @@ -1,7 +1,5 @@ -Workflows -========= -Continuous Categorization -------------------------- +# Workflows +## Continuous Categorization ``` from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth @@ -29,8 +27,7 @@ assert op.succeeded() op = model.predict() assert op.succeeded() ``` -Continuous Mastering --------------------- +## Continuous Mastering ``` from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth From 8e7f89800406ba46a361e04e498637ba8acb8bf9 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Wed, 5 Feb 2020 16:51:24 -0500 Subject: [PATCH 256/632] Fixed: tick issues, internal reference file extensions, specified code in triple-tick blocks. --- docs/user-guide/geo.md | 45 ++++++++++++++------------- docs/user-guide/installation.md | 29 +++++++++-------- docs/user-guide/quickstart.md | 32 +++++++++++-------- docs/user-guide/secure-credentials.md | 20 ++++++------ docs/user-guide/spec.md | 28 ++++++++++------- docs/user-guide/workflows.md | 4 +-- 6 files changed, 85 insertions(+), 73 deletions(-) diff --git a/docs/user-guide/geo.md b/docs/user-guide/geo.md index fb0c27a6..f083cd43 100644 --- a/docs/user-guide/geo.md +++ b/docs/user-guide/geo.md @@ -17,62 +17,63 @@ There are three layers of information, modeled after GeoJSON; see https://tools. Although the Python Geo Interface is non-prescriptive when it comes to the data types of the id and properties, Tamr has a more restricted set of supported types. See https://docs.tamr.com/reference#attribute-types -The ``:class:~tamr_unify_client.models.dataset.resource.Dataset`` class supports the ``__geo_interface__`` property. This will produce one ``FeatureCollection`` for the entire dataset. +The `:class:~tamr_unify_client.models.dataset.resource.Dataset` class supports the `__geo_interface__` property. This will produce one `FeatureCollection` for the entire dataset. -There is a companion iterator ``itergeofeatures()`` that returns a generator that allows you to +There is a companion iterator `itergeofeatures()` that returns a generator that allows you to stream the records in the dataset as Geospatial features. To produce a GeoJSON representation of a dataset: -``` +```python dataset = client.datasets.by_name("my_dataset") with open("my_dataset.json", "w") as f: json.dump(dataset.__geo_interface__, f) ``` -By default, ``itergeofeatures()`` will use the first dataset attribute with geometry type to fill in the feature geometry. You can override this by specifying the geometry attribute to use in the ``geo_attr`` parameter to ``itergeofeatures``. -``Dataset`` can also be updated from a feature collection that supports the Python Geo Interface: -``` +By default, `itergeofeatures()` will use the first dataset attribute with geometry type to fill in the feature geometry. You can override this by specifying the geometry attribute to use in the `geo_attr` parameter to `itergeofeatures`. + +`Dataset` can also be updated from a feature collection that supports the Python Geo Interface: +```python import geopandas geodataframe = geopandas.GeoDataFrame(...) dataset = client.dataset.by_name("my_dataset") dataset.from_geo_features(geodataframe) ``` + By default the features' geometries will be placed into the first dataset attribute with geometry -type. You can override this by specifying the geometry attribute to use in the ``geo_attr`` -parameter to ``from_geo_features``. +type. You can override this by specifying the geometry attribute to use in the `geo_attr` +parameter to `from_geo_features`. ## Rules for converting from Tamr records to Geospatial Features - - -The record's primary key will be used as the feature's ``id``. If the primary key is a single attribute, then the value of that attribute will be the value of ``id``. If the primary key is composed of multiple attributes, then the value of the ``id`` will be an array with the values of the key attributes in order. +The record's primary key will be used as the feature's `id`. If the primary key is a single attribute, then the value of that attribute will be the value of `id`. If the primary key is composed of multiple attributes, then the value of the `id` will be an array with the values of the key attributes in order. Tamr allows any number of geometry attributes per record; the Python Geo Interface is limited to one. When converting Tamr records to Python Geo Features, the first geometry attribute in the schema will be used as the geometry; all other geometry attributes will appear as properties with no type conversion. In the future, additional control over the handling of multiple geometries may be provided; the current set of capabilities is intended primarily to support the use case of working with FeatureCollections within Tamr, and FeatureCollection has only one geometry per feature. -An attribute is considered to have geometry type if it has type ``RECORD`` and contains an attribute named ``point``, ``multiPoint``, ``lineString``, ``multiLineString``, ``polygon``, or ``multiPolygon``. +An attribute is considered to have geometry type if it has type `RECORD` and contains an attribute named `point`, `multiPoint`, `lineString`, `multiLineString`, `polygon`, or `multiPolygon`. -If an attribute named ``bbox`` is available, it will be used as ``bbox``. No conversion is done on the value of ``bbox``. In the future, additional control over the handling of ``bbox`` attributes may be provided. +If an attribute named `bbox` is available, it will be used as `bbox`. No conversion is done on the value of `bbox`. In the future, additional control over the handling of `bbox` attributes may be provided. -All other attributes will be placed in ``properties``, with no type conversion. This includes all geometry attributes other than the first. +All other attributes will be placed in `properties`, with no type conversion. This includes all geometry attributes other than the first. ## Rules for converting from Geospatial Features to Tamr records -The Feature's ``id`` will be converted into the primary key for the record. If the record uses a simple key, no value translation will be done. If the record uses a composite key, then the value of the Feature's ``id`` must be an array of values, one per attribute in the key. +The Feature's `id` will be converted into the primary key for the record. If the record uses a simple key, no value translation will be done. If the record uses a composite key, then the value of the Feature's `id` must be an array of values, one per attribute in the key. -If the Feature contains keys in ``properties`` that conflict with the record keys, ``bbox``, or geometry, those keys are ignored (omitted). +If the Feature contains keys in `properties` that conflict with the record keys, `bbox`, or geometry, those keys are ignored (omitted). -If the Feature contains a ``bbox``, it is copied to the record's ``bbox``. +If the Feature contains a `bbox`, it is copied to the record's `bbox`. -All other keys in the Feature's ``properties`` are propagated to the same-name attribute on the record, with no type conversion. +All other keys in the Feature's `properties` are propagated to the same-name attribute on the record, with no type conversion. ## Streaming data access -The ``Dataset`` method ``itergeofeatures()`` returns a generator that allows you to stream the records in the dataset as Geospatial features: -``` +The `Dataset` method `itergeofeatures()` returns a generator that allows you to stream the records in the dataset as Geospatial features: +```python my_dataset = client.datasets.by_name("my_dataset") for feature in my_dataset.itergeofeatures(): - do_something(feature) + do_something(feature) ``` + Note that many packages that consume the Python Geo Interface will be able to consume this iterator directly. For example:: -``` +```python from geopandas import GeoDataFrame df = GeoDataFrame.from_features(my_dataset.itergeofeatures()) ``` diff --git a/docs/user-guide/installation.md b/docs/user-guide/installation.md index 4ee59bc9..b67f0b79 100644 --- a/docs/user-guide/installation.md +++ b/docs/user-guide/installation.md @@ -1,15 +1,14 @@ # Installation -``tamr-unify-client`` is compatible with Python 3.6 or newer. +`tamr-unify-client` is compatible with Python 3.6 or newer. ## Stable releases Installation is as simple as: -``pip install tamr-unify-client`` +`pip install tamr-unify-client` Or: - -``poetry add tamr-unify-client`` +`poetry add tamr-unify-client` Note: @@ -17,17 +16,17 @@ If you don't use [poetry](https://poetry.eustace.io/), we recommend you use a vi You can create a virtual environment with Python 3 via: -``python3 -m venv my-venv`` +`python3 -m venv my-venv` - For more, see [The Hitchhiker's Guide to Python](https://docs.python-guide.org/dev/virtualenvs/). +For more, see [The Hitchhiker's Guide to Python](https://docs.python-guide.org/dev/virtualenvs/). ## Latest (unstable) Note: -This project uses the new ``pyproject.toml`` file, not a ``setup.py`` file, so make sure you have the latest version of ``pip`` installed: ``pip install -U pip``. +This project uses the new `pyproject.toml` file, not a `setup.py` file, so make sure you have the latest version of `pip` installed: `pip install -U pip`. To install the bleeding edge: -``` +```bash git clone https://github.com/Datatamer/tamr-client cd tamr-client pip install . @@ -35,24 +34,24 @@ pip install . ## Offline installs -First, download ``tamr-unify-client`` and its dependencies on a machine with online access to PyPI: +First, download `tamr-unify-client` and its dependencies on a machine with online access to PyPI: -``` +```bash pip download tamr-unify-client -d tamr-unify-client-requirements zip -r tamr-unify-client-requirements.zip tamr-unify-client-requirements ``` -Then, ship the ``.zip`` file to the target machine where you want ``tamr-unify-client`` installed. You can do this via email, cloud drives, ``scp`` or any other mechanism. +Then, ship the `.zip` file to the target machine where you want `tamr-unify-client` installed. You can do this via email, cloud drives, `scp` or any other mechanism. -Finally, install ``tamr-unify-client`` from the saved dependencies: +Finally, install `tamr-unify-client` from the saved dependencies: -``` +```bash unzip tamr-unify-client-requirements.zip pip install --no-index --find-links=tamr-unify-client-requirements tamr-unify-client ``` -If you are not using a virtual environment, you may need to specify the ``--user`` flag if you get permissions errors: +If you are not using a virtual environment, you may need to specify the `--user` flag if you get permissions errors: -``` +```bash pip install --user --no-index --find-links=tamr-unify-client-requirements tamr-unify-client ``` diff --git a/docs/user-guide/quickstart.md b/docs/user-guide/quickstart.md index 812aab9f..852388d9 100644 --- a/docs/user-guide/quickstart.md +++ b/docs/user-guide/quickstart.md @@ -2,12 +2,13 @@ ## Client configuration Start by importing the Python Client and authentication provider: -``` +```python from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth ``` Next, create an authentication provider and use that to create an authenticated client: -``` + +```python import os username = os.environ['TAMR_USERNAME'] @@ -16,51 +17,56 @@ password = os.environ['TAMR_PASSWORD'] auth = UsernamePasswordAuth(username, password) tamr = Client(auth) ``` + Warning: For security, it's best to read your credentials in from environment variables or secure files instead of hardcoding them directly into your code. -For more, see [User Guide > Secure Credentials](secure-credentials.html). +For more, see [User Guide > Secure Credentials](secure-credentials). -By default, the client tries to find the Tamr instance on ``localhost``. To point to a different host, set the host argument when instantiating the Client. +By default, the client tries to find the Tamr instance on `localhost`. To point to a different host, set the host argument when instantiating the Client. -For example, to connect to ``10.20.0.1``: -``` +For example, to connect to `10.20.0.1`: +```python tamr = Client(auth, host='10.20.0.1') ``` + ## Top-level collections The Python Client exposes 2 top-level collections: Projects and Datasets. You can access these collections through the client and loop over their members -with simple ``for``-loops. +with simple `for`-loops. E.g.: -``` +```python for project in tamr.projects: print(project.name) for dataset in tamr.datasets: print(dataset.name) ``` + ## Fetch a specific resource -If you know the identifier for a specific resource, you can ask for it directly via the ``by_resource_id`` methods exposed by collections. +If you know the identifier for a specific resource, you can ask for it directly via the `by_resource_id` methods exposed by collections. -E.g. To fetch the project with ID ``'1'``: -``` +E.g. To fetch the project with ID `'1'`: +```python project = tamr.projects.by_resource_id('1') ``` + ## Resource relationships Related resources (like a project and its unified dataset) can be accessed through specific methods. E.g. To access the Unified Dataset for a particular project: -``` +```python ud = project.unified_dataset() ``` + ## Kick-off Tamr Operations Some methods on Model objects can kick-off long-running Tamr operations. Here, kick-off a "Unified Dataset refresh" operation: -``` +```python operation = project.unified_dataset().refresh() assert op.succeeded() ``` diff --git a/docs/user-guide/secure-credentials.md b/docs/user-guide/secure-credentials.md index d2ae6397..f3388e4d 100644 --- a/docs/user-guide/secure-credentials.md +++ b/docs/user-guide/secure-credentials.md @@ -3,8 +3,8 @@ This section discusses ways to pass credentials securely to `:class:~tamr_unify_client.auth.UsernamePasswordAuth`. Specifically, you **should not** hardcode your password(s) in your source code. Instead, you should use environment variables or secure files to store your credentials and simple Python code to read your credentials. ## Environment variables -You can use ``os.environ`` to read in your credentials from environment variables: -``` +You can use `os.environ` to read in your credentials from environment variables: +```python # my_script.py import os @@ -17,23 +17,24 @@ auth = UsernamePasswordAuth(username, password) ``` You can pass in the environment variables from the terminal by including them before your command: -``` +```bash TAMR_USERNAME="my Tamr username" TAMR_PASSWORD="my Tamr password" python my_script.py ``` -You can also create an ``.sh`` file to store your environment variables and -simply ``source`` that file before running your script. +You can also create an `.sh` file to store your environment variables and +simply `source` that file before running your script. ## Config files You can also store your credentials in a secure credentials file: -``` +```yaml # credentials.yaml --- username: "my tamr username" password: "my tamr password" ``` -Then ``pip install pyyaml`` read the credentials in your Python code: -``` + +Then `pip install pyyaml` read the credentials in your Python code: +```python # my_script.py from tamr_unify_client.auth import UsernamePasswordAuth import yaml @@ -43,7 +44,8 @@ with open("path/to/credentials.yaml") as f: # replace with your credentials.yaml auth = UsernamePasswordAuth(creds['username'], creds['password']) ``` + As in this example, we recommend you use YAML as your format since YAML has support for comments and is more human-readable than JSON. Important: -You **should not** check these credentials files into your version control system (e.g. ``git``). Do not share this file with anyone who should not have access to the password stored in it. +You **should not** check these credentials files into your version control system (e.g. `git`). Do not share this file with anyone who should not have access to the password stored in it. diff --git a/docs/user-guide/spec.md b/docs/user-guide/spec.md index 25c001c1..85d31b60 100644 --- a/docs/user-guide/spec.md +++ b/docs/user-guide/spec.md @@ -1,8 +1,8 @@ # Creating and Modifying Resources ## Creating resources -Resources, such as projects, dataset, and attribute configurations, can be created through their respective collections. Each ``create`` function takes in a dictionary that conforms to the +Resources, such as projects, dataset, and attribute configurations, can be created through their respective collections. Each `create` function takes in a dictionary that conforms to the [Tamr Public Docs](https://docs.tamr.com/reference) for creating that resource type: -``` +```python spec = { "name": "project", "description": "Mastering Project", @@ -11,15 +11,16 @@ spec = { } project = tamr.projects.create(spec) ``` + ## Using specs These dictionaries can also be created using spec classes. -Each ``Resource`` has a corresponding ``ResourceSpec`` which can be used to build an instance of that resource by specifying the value for each property. +Each `Resource` has a corresponding `ResourceSpec` which can be used to build an instance of that resource by specifying the value for each property. -The spec can then be converted to a dictionary that can be passed to ``create``. +The spec can then be converted to a dictionary that can be passed to `create`. For instance, to create a project: -``` +```python spec = ( ProjectSpec.new() .with_name("Project") @@ -30,8 +31,9 @@ spec = ( ) project = tamr.projects.create(spec.to_dict()) ``` -Calling ``with_*`` on a spec creates a new spec with the same properties besides the modified one. The original spec is unaltered, so it could be used multiple times: -``` + +Calling `with_*` on a spec creates a new spec with the same properties besides the modified one. The original spec is unaltered, so it could be used multiple times: +```python base_spec = ( ProjectSpec.new() .with_type("DEDUP") @@ -48,26 +50,28 @@ for name in project_names: projects = [tamr.projects.create(spec.to_dict()) for spec in specs] ``` + ## Creating a dataset Datasets can be created as described above, but the dataset's schema and records must then be handled separately. -To combine all of these steps into one, ``DatasetCollection`` has a convenience function ``create_from_dataframe`` that takes a [Pandas DataFrame](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.html). +To combine all of these steps into one, `DatasetCollection` has a convenience function `create_from_dataframe` that takes a [Pandas DataFrame](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.html). This makes it easy to create a Tamr dataset from a CSV: -``` +```python import pandas as pd df = pd.read_csv("my_data.csv") dataset = tamr.datasets.create_from_dataframe(df, "primary key name", "My Data") ``` + This will create a dataset called "My Data" with the specified primary key, an attribute -for each column of the ``DataFrame``, and the ``DataFrame``'s rows as records. +for each column of the `DataFrame`, and the `DataFrame`'s rows as records. ## Modifying a resource Certain resources can also be modified using specs. After getting a spec corresponding to a resource and modifying some properties, -the updated resource can be committed to Tamr with the ``put`` function: -``` +the updated resource can be committed to Tamr with the `put` function: +```python updated_dataset = ( dataset.spec() .with_description("Modified description") diff --git a/docs/user-guide/workflows.md b/docs/user-guide/workflows.md index de7ca1d0..517dd12e 100644 --- a/docs/user-guide/workflows.md +++ b/docs/user-guide/workflows.md @@ -1,6 +1,6 @@ # Workflows ## Continuous Categorization -``` +```python from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth import os @@ -28,7 +28,7 @@ op = model.predict() assert op.succeeded() ``` ## Continuous Mastering -``` +```python from tamr_unify_client import Client from tamr_unify_client.auth import UsernamePasswordAuth import os From 7c6a794e8ee71dafd72d0db91481ac1af75749c7 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Wed, 19 Feb 2020 13:43:17 -0500 Subject: [PATCH 257/632] Fixed Note and Warning boxes. --- docs/user-guide/geo.md | 8 ++++---- docs/user-guide/installation.md | 21 ++++++++++----------- docs/user-guide/quickstart.md | 8 +++----- docs/user-guide/secure-credentials.md | 7 ++++--- 4 files changed, 21 insertions(+), 23 deletions(-) diff --git a/docs/user-guide/geo.md b/docs/user-guide/geo.md index f083cd43..dc74a730 100644 --- a/docs/user-guide/geo.md +++ b/docs/user-guide/geo.md @@ -1,8 +1,8 @@ # Geospatial Data ## What geospatial data is supported? -In general, the Python Geo Interface is supported; see https://gist.github.com/sgillies/2217756 +In general, the Python Geo Interface is supported; see . -There are three layers of information, modeled after GeoJSON; see https://tools.ietf.org/html/rfc7946 +There are three layers of information, modeled after GeoJSON (see ): * The outermost layer is a FeatureCollection * Within a FeatureCollection are Features, each of which represents one "thing", like a building or a river. Each feature has: @@ -15,9 +15,9 @@ There are three layers of information, modeled after GeoJSON; see https://tools. * type (one of "Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon"; required) * coordinates (doubles; exactly how these are structured depends on the type of the geometry) -Although the Python Geo Interface is non-prescriptive when it comes to the data types of the id and properties, Tamr has a more restricted set of supported types. See https://docs.tamr.com/reference#attribute-types +Although the Python Geo Interface is non-prescriptive when it comes to the data types of the id and properties, Tamr has a more restricted set of supported types. See . -The `:class:~tamr_unify_client.models.dataset.resource.Dataset` class supports the `__geo_interface__` property. This will produce one `FeatureCollection` for the entire dataset. +The `Dataset` class supports the `__geo_interface__` property. This will produce one `FeatureCollection` for the entire dataset. There is a companion iterator `itergeofeatures()` that returns a generator that allows you to stream the records in the dataset as Geospatial features. diff --git a/docs/user-guide/installation.md b/docs/user-guide/installation.md index b67f0b79..78f484ed 100644 --- a/docs/user-guide/installation.md +++ b/docs/user-guide/installation.md @@ -8,23 +8,22 @@ Installation is as simple as: `pip install tamr-unify-client` Or: -`poetry add tamr-unify-client` - -Note: -If you don't use [poetry](https://poetry.eustace.io/), we recommend you use a virtual environment for your project and install the Python Client into that virtual environment. +`poetry add tamr-unify-client` -You can create a virtual environment with Python 3 via: +``` note:: + If you don't use `poetry `_, we recommend you use a virtual environment for your project and install the Python Client into that virtual environment. -`python3 -m venv my-venv` + You can create a virtual environment with Python 3 via: -For more, see [The Hitchhiker's Guide to Python](https://docs.python-guide.org/dev/virtualenvs/). + ``python3 -m venv my-venv`` + For more, see `The Hitchhiker's Guide to Python `_. +``` ## Latest (unstable) -Note: - -This project uses the new `pyproject.toml` file, not a `setup.py` file, so make sure you have the latest version of `pip` installed: `pip install -U pip`. - +``` note:: + This project uses the new ``pyproject.toml`` file, not a ``setup.py`` file, so make sure you have the latest version of ``pip`` installed: ```pip install -U pip``. +``` To install the bleeding edge: ```bash git clone https://github.com/Datatamer/tamr-client diff --git a/docs/user-guide/quickstart.md b/docs/user-guide/quickstart.md index 852388d9..80abf623 100644 --- a/docs/user-guide/quickstart.md +++ b/docs/user-guide/quickstart.md @@ -18,12 +18,10 @@ auth = UsernamePasswordAuth(username, password) tamr = Client(auth) ``` -Warning: - -For security, it's best to read your credentials in from environment variables or secure files instead of hardcoding them directly into your code. - -For more, see [User Guide > Secure Credentials](secure-credentials). +``` warning:: For security, it's best to read your credentials in from environment variables or secure files instead of hardcoding them directly into your code. + For more, see `User Guide > Secure Credentials `_. +``` By default, the client tries to find the Tamr instance on `localhost`. To point to a different host, set the host argument when instantiating the Client. For example, to connect to `10.20.0.1`: diff --git a/docs/user-guide/secure-credentials.md b/docs/user-guide/secure-credentials.md index f3388e4d..72578d21 100644 --- a/docs/user-guide/secure-credentials.md +++ b/docs/user-guide/secure-credentials.md @@ -1,6 +1,6 @@ # Secure Credentials This section discusses ways to pass credentials securely to -`:class:~tamr_unify_client.auth.UsernamePasswordAuth`. Specifically, you **should not** hardcode your password(s) in your source code. Instead, you should use environment variables or secure files to store your credentials and simple Python code to read your credentials. +`UsernamePasswordAuth`. Specifically, you **should not** hardcode your password(s) in your source code. Instead, you should use environment variables or secure files to store your credentials and simple Python code to read your credentials. ## Environment variables You can use `os.environ` to read in your credentials from environment variables: @@ -47,5 +47,6 @@ auth = UsernamePasswordAuth(creds['username'], creds['password']) As in this example, we recommend you use YAML as your format since YAML has support for comments and is more human-readable than JSON. -Important: -You **should not** check these credentials files into your version control system (e.g. `git`). Do not share this file with anyone who should not have access to the password stored in it. +``` important:: + You **should not** check these credentials files into your version control system (e.g. ``git``). Do not share this file with anyone who should not have access to the password stored in it. +``` From 7b21cf62b7b05ac0f5de8bcfc8798e9d9e087692 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 18 Feb 2020 15:51:53 -0500 Subject: [PATCH 258/632] Module-level docstrings with link to docs.tamr.com section for attributes --- tamr_client/attributes/attribute.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tamr_client/attributes/attribute.py b/tamr_client/attributes/attribute.py index a5696927..4a8450c2 100644 --- a/tamr_client/attributes/attribute.py +++ b/tamr_client/attributes/attribute.py @@ -1,3 +1,6 @@ +""" +See https://docs.tamr.com/reference/attribute-types +""" from copy import deepcopy from dataclasses import dataclass, field, replace from typing import Optional From 9c8e774a23011f025a7e49ff6c1ff3db40ff477b Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 18 Feb 2020 15:52:36 -0500 Subject: [PATCH 259/632] fix(typecheck): previously, mypy was unable to infer types for DEFAULT and GEOSPATIAL --- tamr_client/attributes/type_alias.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tamr_client/attributes/type_alias.py b/tamr_client/attributes/type_alias.py index eee71cab..5e68e940 100644 --- a/tamr_client/attributes/type_alias.py +++ b/tamr_client/attributes/type_alias.py @@ -1,9 +1,9 @@ import tamr_client as tc from tamr_client.attributes.attribute_type import Array, DOUBLE, Record, STRING -DEFAULT = Array(STRING) +DEFAULT: Array = Array(STRING) -GEOSPATIAL = Record( +GEOSPATIAL: Record = Record( attributes=( tc.SubAttribute(name="point", is_nullable=True, type=Array(DOUBLE)), tc.SubAttribute(name="multiPoint", is_nullable=True, type=Array(Array(DOUBLE))), From c70f8252a3bbbcb97ada05dbc03d63800b84c9fb Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 18 Feb 2020 15:53:24 -0500 Subject: [PATCH 260/632] fix(typecheck): requests.Session does not have any constructor args --- tamr_client/session.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tamr_client/session.py b/tamr_client/session.py index aa60b135..e8ea5728 100644 --- a/tamr_client/session.py +++ b/tamr_client/session.py @@ -3,12 +3,12 @@ Session = requests.Session -def from_auth(auth: requests.auth.HTTPBasicAuth, **kwargs) -> Session: +def from_auth(auth: requests.auth.HTTPBasicAuth) -> Session: """Create a new authenticated session Args: auth: Authentication """ - s = requests.Session(**kwargs) + s = requests.Session() s.auth = auth return s From e24b7e441ab5ec51108bfcb7d298254148b5985c Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 18 Feb 2020 15:54:08 -0500 Subject: [PATCH 261/632] tc.instance module tc.Instance for http origin tc.URL changed to directly reference an instance --- tamr_client/instance.py | 16 ++++++++++++++++ tamr_client/url.py | 9 +++++---- 2 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 tamr_client/instance.py diff --git a/tamr_client/instance.py b/tamr_client/instance.py new file mode 100644 index 00000000..e9bf9f6b --- /dev/null +++ b/tamr_client/instance.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Instance: + protocol: str = "http" + host: str = "localhost" + port: int = 9100 + + +def origin(instance: Instance) -> str: + """HTTP origin i.e. :code:`://[:]`. + + For additional information, see `MDN web docs `_ . + """ + return f"{instance.protocol}://{instance.host}:{instance.port}" diff --git a/tamr_client/url.py b/tamr_client/url.py index 68d6b7f7..4a110cdb 100644 --- a/tamr_client/url.py +++ b/tamr_client/url.py @@ -1,13 +1,14 @@ from dataclasses import dataclass +import tamr_client as tc + @dataclass(frozen=True) class URL: path: str - protocol: str = "http" - host: str = "localhost" - port: int = 9100 + instance: tc.Instance = tc.Instance() base_path: str = "api/versioned/v1" def __str__(self): - return f"{self.protocol}://{self.host}:{self.port}/{self.base_path}/{self.path}" + origin = tc.instance.origin(self.instance) + return f"{origin}/{self.base_path}/{self.path}" From fbf02a2d6811ff3931407efaecf4f5c2dc6cad52 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 18 Feb 2020 15:55:00 -0500 Subject: [PATCH 262/632] feat: tc.dataset - tc.Dataset - tc.dataset.from_resource_id --- tamr_client/datasets/dataset.py | 80 ++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/tamr_client/datasets/dataset.py b/tamr_client/datasets/dataset.py index 8b95fe03..f83b6c37 100644 --- a/tamr_client/datasets/dataset.py +++ b/tamr_client/datasets/dataset.py @@ -1,13 +1,91 @@ +""" +See https://docs.tamr.com/reference/dataset-models +""" +from copy import deepcopy from dataclasses import dataclass, replace -from typing import Tuple +from typing import Optional, Tuple import tamr_client as tc +from tamr_client.types import JsonDict + + +class DatasetNotFound(Exception): + """Raised when referencing (e.g. updating or deleting) a dataset + that does not exist on the server. + """ + + pass @dataclass(frozen=True) class Dataset: + """A Tamr dataset + + See https://docs.tamr.com/reference/dataset-models + + Args: + url + key_attribute_names + """ + url: tc.URL + name: str key_attribute_names: Tuple[str, ...] + description: Optional[str] = None + + +def from_resource_id(session: tc.Session, instance: tc.Instance, id: str) -> Dataset: + """Get dataset by resource ID + + Fetches dataset from Tamr server + + Args: + instance: Tamr instance containing this dataset + id: Dataset ID + + Raises: + DatasetNotFound: If no dataset could be found at the specified URL. + Corresponds to a 404 HTTP error. + requests.HTTPError: If any other HTTP error is encountered. + """ + url = tc.URL(instance=instance, path=f"datasets/{id}") + return _from_url(session, url) + + +def _from_url(session: tc.Session, url: tc.URL) -> Dataset: + """Get dataset by URL + + Fetches dataset from Tamr server + + Args: + url: Dataset URL + + Raises: + DatasetNotFound: If no dataset could be found at the specified URL. + Corresponds to a 404 HTTP error. + requests.HTTPError: If any other HTTP error is encountered. + """ + r = session.get(str(url)) + if r.status_code == 404: + raise DatasetNotFound(str(url)) + data = tc.response.successful(r).json() + return _from_json(url, data) + + +def _from_json(url: tc.URL, data: JsonDict) -> Dataset: + """Make dataset from JSON data (deserialize) + + Args: + url: Dataset URL + data: Dataset JSON data from Tamr server + """ + cp = deepcopy(data) + return Dataset( + url, + name=cp["name"], + description=cp.get("description"), + key_attribute_names=tuple(cp["keyAttributeNames"]), + ) def attributes(session: tc.Session, dataset: Dataset) -> Tuple["tc.Attribute", ...]: From 2ee9cc834778109c7d865cb5fb43b3e220a544c4 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 18 Feb 2020 15:56:06 -0500 Subject: [PATCH 263/632] import shortcuts for new features --- tamr_client/__init__.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 3d583e07..35d7cc7d 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -3,16 +3,25 @@ import logging # utilities +import tamr_client.response as response + +# instance +from tamr_client.instance import Instance +import tamr_client.instance as instance + +# url from tamr_client.url import URL import tamr_client.url as url -import tamr_client.response as response +# auth from tamr_client.auth import UsernamePasswordAuth + +# session from tamr_client.session import Session import tamr_client.session as session # datasets -from tamr_client.datasets.dataset import Dataset +from tamr_client.datasets.dataset import Dataset, DatasetNotFound import tamr_client.datasets.dataset as dataset # attributes @@ -24,10 +33,12 @@ import tamr_client.attributes.type_alias as attribute_type_alias -from tamr_client.attributes.attribute import Attribute -from tamr_client.attributes.attribute import ReservedAttributeName -from tamr_client.attributes.attribute import AttributeExists -from tamr_client.attributes.attribute import AttributeNotFound +from tamr_client.attributes.attribute import ( + Attribute, + ReservedAttributeName, + AttributeExists, + AttributeNotFound, +) import tamr_client.attributes.attribute as attribute # https://docs.python-guide.org/writing/logging/#logging-in-a-library From 89761f9fd8577761bcb63a55a104fb0fe1ddfe68 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 18 Feb 2020 15:56:22 -0500 Subject: [PATCH 264/632] fix(typecheck): type check ALL python in tamr_client also specify python code by package (not by file) --- tasks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tasks.py b/tasks.py index 57aa0953..831874b0 100644 --- a/tasks.py +++ b/tasks.py @@ -20,14 +20,14 @@ def typecheck(c, warn=True): tc = repo / "tamr_client" tests = repo / "tests" pkgs = [ + tc, tc / "attributes", tests / "attributes", tc / "datasets", tests / "datasets", ] for pkg in pkgs: - pyfiles = " ".join(str(pyfile) for pyfile in pkg.glob("**/*.py")) - c.run(f"poetry run mypy {pyfiles}", echo=True, pty=True, warn=warn) + c.run(f"poetry run mypy {str(pkg)}", echo=True, pty=True, warn=warn) @task From 5fa7959c873587e912311e94b7891163aeed9220 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 18 Feb 2020 15:57:18 -0500 Subject: [PATCH 265/632] test: tc.dataset.from_resource_id --- tests/datasets/test_dataset.py | 28 ++++++++++++++++++++++++++++ tests/utils.py | 6 +++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/tests/datasets/test_dataset.py b/tests/datasets/test_dataset.py index 578b1386..27455d9d 100644 --- a/tests/datasets/test_dataset.py +++ b/tests/datasets/test_dataset.py @@ -1,11 +1,39 @@ from dataclasses import replace +import pytest import responses import tamr_client as tc import tests.utils as utils +@responses.activate +def test_from_resource_id(): + s = utils.session() + instance = utils.instance() + + dataset_json = utils.load_json("dataset.json") + url = tc.URL(path="datasets/1") + responses.add(responses.GET, str(url), json=dataset_json) + + dataset = tc.dataset.from_resource_id(s, instance, "1") + assert dataset.name == "dataset 1 name" + assert dataset.description == "dataset 1 description" + assert dataset.key_attribute_names == ("tamr_id",) + + +@responses.activate +def test_from_resource_id_dataset_not_found(): + s = utils.session() + instance = utils.instance() + + url = tc.URL(path="datasets/1") + responses.add(responses.GET, str(url), status=404) + + with pytest.raises(tc.DatasetNotFound): + tc.dataset.from_resource_id(s, instance, "1") + + @responses.activate def test_attributes(): s = utils.session() diff --git a/tests/utils.py b/tests/utils.py index f19aaec2..0183a911 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -18,7 +18,11 @@ def session(): return s +def instance(): + return tc.Instance() + + def dataset(): url = tc.URL(path="datasets/1") - dataset = tc.Dataset(url, key_attribute_names=("primary_key",)) + dataset = tc.Dataset(url, name="dataset.csv", key_attribute_names=("primary_key",)) return dataset From 29763a745ca09e011d5fb27bdbf41deb6dfeda84 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 18 Feb 2020 15:57:43 -0500 Subject: [PATCH 266/632] docs: tc.dataset - tc.Dataset - tc.dataset.from_resource_id --- docs/reference/beta/datasets.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/reference/beta/datasets.rst b/docs/reference/beta/datasets.rst index d8d7c1dc..df92ca32 100644 --- a/docs/reference/beta/datasets.rst +++ b/docs/reference/beta/datasets.rst @@ -4,4 +4,13 @@ Datasets Dataset ------- +.. autoclass:: tamr_client.Dataset + +.. autofunction:: tamr_client.dataset.from_resource_id .. autofunction:: tamr_client.dataset.attributes + +Exceptions +^^^^^^^^^^ + +.. autoclass:: tamr_client.DatasetNotFound + :no-inherited-members: From 77f0c92ac7921333a8f08a9f3529732a8f444ec9 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 19 Feb 2020 11:02:40 -0500 Subject: [PATCH 267/632] docs: Restructure BETA docs Mirror directory structure from source code --- docs/beta.md | 12 +++++ docs/beta/attributes.md | 5 ++ docs/beta/attributes/attribute.rst | 22 +++++++++ .../attributes/attribute_type.rst} | 47 +------------------ docs/beta/attributes/subattribute.rst | 16 +++++++ docs/beta/datasets.md | 3 ++ .../datasets/dataset.rst} | 7 +-- docs/index.md | 4 ++ docs/reference.md | 8 ---- 9 files changed, 66 insertions(+), 58 deletions(-) create mode 100644 docs/beta.md create mode 100644 docs/beta/attributes.md create mode 100644 docs/beta/attributes/attribute.rst rename docs/{reference/beta/attributes.rst => beta/attributes/attribute_type.rst} (53%) create mode 100644 docs/beta/attributes/subattribute.rst create mode 100644 docs/beta/datasets.md rename docs/{reference/beta/datasets.rst => beta/datasets/dataset.rst} (85%) diff --git a/docs/beta.md b/docs/beta.md new file mode 100644 index 00000000..d15c3d14 --- /dev/null +++ b/docs/beta.md @@ -0,0 +1,12 @@ +# BETA + + **WARNING**: Do not rely on BETA features in production workflows. + Tamr will not offer support for BETA features. + +## Reference + + * [Attributes](beta/attributes) + * [Auth](beta/auth) + * [Dataset](beta/datasets) + * [Instance](beta/instance) + * [Session](beta/session) diff --git a/docs/beta/attributes.md b/docs/beta/attributes.md new file mode 100644 index 00000000..6f2ab055 --- /dev/null +++ b/docs/beta/attributes.md @@ -0,0 +1,5 @@ +# Attributes + + * [Attribute](/beta/attributes/attribute) + * [Attribute Type](/beta/attributes/attribute_type) + * [SubAttribute](/beta/attributes/subattribute) diff --git a/docs/beta/attributes/attribute.rst b/docs/beta/attributes/attribute.rst new file mode 100644 index 00000000..d12b4b32 --- /dev/null +++ b/docs/beta/attributes/attribute.rst @@ -0,0 +1,22 @@ +Attribute +========= + +.. autoclass:: tamr_client.Attribute + +.. autofunction:: tamr_client.attribute.from_resource_id +.. autofunction:: tamr_client.attribute.to_json +.. autofunction:: tamr_client.attribute.create +.. autofunction:: tamr_client.attribute.update +.. autofunction:: tamr_client.attribute.delete + +Exceptions +---------- + +.. autoclass:: tamr_client.ReservedAttributeName + :no-inherited-members: + +.. autoclass:: tamr_client.AttributeExists + :no-inherited-members: + +.. autoclass:: tamr_client.AttributeNotFound + :no-inherited-members: diff --git a/docs/reference/beta/attributes.rst b/docs/beta/attributes/attribute_type.rst similarity index 53% rename from docs/reference/beta/attributes.rst rename to docs/beta/attributes/attribute_type.rst index f0c68e0c..d70c04cc 100644 --- a/docs/reference/beta/attributes.rst +++ b/docs/beta/attributes/attribute_type.rst @@ -1,31 +1,5 @@ -Attributes -========== - -Attribute ---------- - -.. autoclass:: tamr_client.Attribute - -.. autofunction:: tamr_client.attribute.from_resource_id -.. autofunction:: tamr_client.attribute.to_json -.. autofunction:: tamr_client.attribute.create -.. autofunction:: tamr_client.attribute.update -.. autofunction:: tamr_client.attribute.delete - -Exceptions -^^^^^^^^^^ - -.. autoclass:: tamr_client.ReservedAttributeName - :no-inherited-members: - -.. autoclass:: tamr_client.AttributeExists - :no-inherited-members: - -.. autoclass:: tamr_client.AttributeNotFound - :no-inherited-members: - AttributeType -------------- +============= See https://docs.tamr.com/reference#attribute-types @@ -58,24 +32,7 @@ See https://docs.tamr.com/reference#attribute-types .. autofunction:: tamr_client.attribute_type.to_json Type aliases -^^^^^^^^^^^^ +------------ .. autodata:: tamr_client.attribute_type_alias.DEFAULT .. autodata:: tamr_client.attribute_type_alias.GEOSPATIAL - -SubAttribute ------------- - -.. class:: tamr_client.SubAttribute(name, type, is_nullable, description=None) - - :param name: - :type name: :class:`str` - :param type: - :type type: :class:`~tamr_client.AttributeType` - :param is_nullable: - :type is_nullable: :class:`bool` - :param description: - :type description: :class:`~typing.Optional` [:class:`str`] - -.. autofunction:: tamr_client.subattribute.from_json -.. autofunction:: tamr_client.subattribute.to_json diff --git a/docs/beta/attributes/subattribute.rst b/docs/beta/attributes/subattribute.rst new file mode 100644 index 00000000..105bd7f2 --- /dev/null +++ b/docs/beta/attributes/subattribute.rst @@ -0,0 +1,16 @@ +SubAttribute +============ + +.. class:: tamr_client.SubAttribute(name, type, is_nullable, description=None) + + :param name: + :type name: :class:`str` + :param type: + :type type: :class:`~tamr_client.AttributeType` + :param is_nullable: + :type is_nullable: :class:`bool` + :param description: + :type description: :class:`~typing.Optional` [:class:`str`] + +.. autofunction:: tamr_client.subattribute.from_json +.. autofunction:: tamr_client.subattribute.to_json diff --git a/docs/beta/datasets.md b/docs/beta/datasets.md new file mode 100644 index 00000000..2b80afa8 --- /dev/null +++ b/docs/beta/datasets.md @@ -0,0 +1,3 @@ +# Datasets + + * [Dataset](/beta/datasets/dataset) diff --git a/docs/reference/beta/datasets.rst b/docs/beta/datasets/dataset.rst similarity index 85% rename from docs/reference/beta/datasets.rst rename to docs/beta/datasets/dataset.rst index df92ca32..89ef25f4 100644 --- a/docs/reference/beta/datasets.rst +++ b/docs/beta/datasets/dataset.rst @@ -1,8 +1,5 @@ -Datasets -======== - Dataset -------- +======= .. autoclass:: tamr_client.Dataset @@ -10,7 +7,7 @@ Dataset .. autofunction:: tamr_client.dataset.attributes Exceptions -^^^^^^^^^^ +---------- .. autoclass:: tamr_client.DatasetNotFound :no-inherited-members: diff --git a/docs/index.md b/docs/index.md index 73f459a1..884e25f1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -44,3 +44,7 @@ assert op.succeeded() ## Reference * [Reference](reference) + +## BETA + + * [BETA](beta) diff --git a/docs/reference.md b/docs/reference.md index a4c18cb5..66181985 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -9,11 +9,3 @@ * [Mastering](reference/mastering) * [Operations](reference/operation) * [Projects](reference/project) - -## BETA - - **WARNING**: Do not rely on BETA features in production workflows. - Tamr will not offer support for BETA features. - - * [Attributes](reference/beta/attributes) - * [Datasets](reference/beta/datasets) From 9ab6ebc67db0d0ce0b3af6728c5b5343e270d2f0 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 19 Feb 2020 11:03:26 -0500 Subject: [PATCH 268/632] docs - tc.auth - tc.instance - tc.session --- docs/beta/auth.rst | 4 ++++ docs/beta/instance.rst | 6 ++++++ docs/beta/session.rst | 6 ++++++ 3 files changed, 16 insertions(+) create mode 100644 docs/beta/auth.rst create mode 100644 docs/beta/instance.rst create mode 100644 docs/beta/session.rst diff --git a/docs/beta/auth.rst b/docs/beta/auth.rst new file mode 100644 index 00000000..0ac54cfc --- /dev/null +++ b/docs/beta/auth.rst @@ -0,0 +1,4 @@ +Auth +==== + +.. autoclass:: tamr_client.UsernamePasswordAuth diff --git a/docs/beta/instance.rst b/docs/beta/instance.rst new file mode 100644 index 00000000..339d21a6 --- /dev/null +++ b/docs/beta/instance.rst @@ -0,0 +1,6 @@ +Instance +======== + +.. autoclass:: tamr_client.Instance + +.. autofunction:: tamr_client.instance.origin diff --git a/docs/beta/session.rst b/docs/beta/session.rst new file mode 100644 index 00000000..4b684279 --- /dev/null +++ b/docs/beta/session.rst @@ -0,0 +1,6 @@ +Session +======= + +.. autoclass:: tamr_client.Session + +.. autofunction:: tamr_client.session.from_auth From da9e3f986e92f92da0206a0aee3a351fca473b32 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 19 Feb 2020 11:13:17 -0500 Subject: [PATCH 269/632] Remove import shortcut for attribute type aliases tc.attributes.type_alias (actual module path) is just as usable (if not more usable) as tc.attribute_type_alias --- docs/beta/attributes/attribute_type.rst | 4 ++-- tamr_client/__init__.py | 2 +- tamr_client/attributes/attribute.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/beta/attributes/attribute_type.rst b/docs/beta/attributes/attribute_type.rst index d70c04cc..130f0839 100644 --- a/docs/beta/attributes/attribute_type.rst +++ b/docs/beta/attributes/attribute_type.rst @@ -34,5 +34,5 @@ See https://docs.tamr.com/reference#attribute-types Type aliases ------------ -.. autodata:: tamr_client.attribute_type_alias.DEFAULT -.. autodata:: tamr_client.attribute_type_alias.GEOSPATIAL +.. autodata:: tamr_client.attributes.type_alias.DEFAULT +.. autodata:: tamr_client.attributes.type_alias.GEOSPATIAL diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 35d7cc7d..7b94235b 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -31,7 +31,7 @@ from tamr_client.attributes.attribute_type import AttributeType import tamr_client.attributes.attribute_type as attribute_type -import tamr_client.attributes.type_alias as attribute_type_alias +import tamr_client.attributes.type_alias from tamr_client.attributes.attribute import ( Attribute, diff --git a/tamr_client/attributes/attribute.py b/tamr_client/attributes/attribute.py index 4a8450c2..413de3ef 100644 --- a/tamr_client/attributes/attribute.py +++ b/tamr_client/attributes/attribute.py @@ -149,7 +149,7 @@ def create( *, name: str, is_nullable: bool, - type: tc.attribute_type.AttributeType = tc.attribute_type_alias.DEFAULT, + type: tc.attribute_type.AttributeType = tc.attributes.type_alias.DEFAULT, description: Optional[str] = None, ) -> Attribute: """Create an attribute @@ -192,7 +192,7 @@ def _create( *, name: str, is_nullable: bool, - type: tc.attribute_type.AttributeType = tc.attribute_type_alias.DEFAULT, + type: tc.attribute_type.AttributeType = tc.attributes.type_alias.DEFAULT, description: Optional[str] = None, ) -> Attribute: """Same as `tc.attribute.create`, but does not check for reserved attribute From 5a61ca5e4171725a00c74c6019af54dffbe0c5af Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 19 Feb 2020 11:14:36 -0500 Subject: [PATCH 270/632] changelog: instance and dataset also update entries that were updated in the BETA --- CHANGELOG.md | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b232b4c3..3e49b0e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,26 +2,34 @@ **NEW FEATURES** - BETA: New attributes package! - `tc.attribute` module - - `tc.Attribute` class + - `tc.Attribute` type - functions: `from_resource_id`, `to_json`, `create`, `update`, `delete` - `tc.attribute_type` module - `tc.AttributeType` for type annotations - Primitive Types: `BOOLEAN`, `DOUBLE`, `INT`, `LONG`, `STRING` - Complex Types: `Array`, `Map`, `Record` - - Type aliases: `DEFAULT`, `GEOSPATIAL` - functions: `from_json`, `to_json` - `tc.subattribute` module - - `tc.SubAttribute` class + - `tc.SubAttribute` type - functions: `from_json`, `to_json` + - `tc.attributes.type_alias` module + - `tc.attributes.type_alias.DEFAULT` type + - `tc.attributes.type_alias.GEOSPATIAL` type - BETA: New datasets package! - `tc.dataset` module - - functions: `_attributes` + - `tc.Dataset` type + - functions: `from_resource_id`, `attributes` + - BETA: New `tc.instance` module! + - `tc.Instance` type + - functions: `tc.instance.from_auth` - BETA: New supporting modules! - `tc.auth` module - - `tc.UsernamePasswordAuth` class - - `tc.session` function + - `tc.UsernamePasswordAuth` type + - `tc.session` module + - `tc.Session` type + - functions: `tc.session.from_auth` - `tc.url` module - - `tc.URL` class + - `tc.URL` type **BUG FIXES** - Links from our docs to the `requests` docs were outdated. Links have been updated to point to the new `requests` docs URL. From 255ec94aae981b2377fc1cb7cc60e6b3fe1eb728 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 19 Feb 2020 11:19:53 -0500 Subject: [PATCH 271/632] docs: Reference requests.Session docs ...rather than inlining them in our own docs --- docs/beta/session.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/beta/session.rst b/docs/beta/session.rst index 4b684279..e53e4ac2 100644 --- a/docs/beta/session.rst +++ b/docs/beta/session.rst @@ -1,6 +1,8 @@ Session ======= -.. autoclass:: tamr_client.Session +The :class:`~tamr_client.Session` type is an alias for :class:`requests.Session`. + +For more information, see the official :class:`requests.Session` docs. .. autofunction:: tamr_client.session.from_auth From 0f5002aa7f712b0c54b20473a036e59483702e4a Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 19 Feb 2020 16:24:16 -0500 Subject: [PATCH 272/632] explicit opt-in BETA feature flag --- tamr_client/__init__.py | 25 +++++++++++++++++++++++++ tasks.py | 10 ++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 7b94235b..5a9ac3e2 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -1,5 +1,30 @@ # flake8: noqa +############ +# BETA start +############ + +import os +import sys + +beta_flag_name = "TAMR_CLIENT_BETA" +beta_flag_value = "1" +beta_flag = os.environ.get(beta_flag_name) + +if beta_flag != beta_flag_value: + msg = ( + f"'tamr_client' package is in BETA, but you do not have the '{beta_flag_name}' environment variable set to '1'." + f"\nSet '{beta_flag_name}=1' to opt-in to BETA features." + "\n\nWARNING: Do not rely on BETA features in production workflows." + "\nTamr will not offer support for BETA features." + ) + print(msg) + sys.exit(1) + +########## +# BETA end +########## + import logging # utilities diff --git a/tasks.py b/tasks.py index 831874b0..f5f1cbe9 100644 --- a/tasks.py +++ b/tasks.py @@ -2,6 +2,8 @@ from invoke import task +beta = "TAMR_CLIENT_BETA=1" + @task def lint(c): @@ -32,9 +34,13 @@ def typecheck(c, warn=True): @task def test(c): - c.run("poetry run pytest", echo=True, pty=True) + c.run(f"{beta} poetry run pytest", echo=True, pty=True) @task def docs(c): - c.run("poetry run sphinx-build -b html docs docs/_build -W", echo=True, pty=True) + c.run( + f"{beta} poetry run sphinx-build -b html docs docs/_build -W", + echo=True, + pty=True, + ) From 2ccedef2e031727b7437cb2a70bb163094dc6d1e Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 19 Feb 2020 16:35:12 -0500 Subject: [PATCH 273/632] HINT for non-BETA --- tamr_client/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 5a9ac3e2..3f23e701 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -13,8 +13,9 @@ if beta_flag != beta_flag_value: msg = ( - f"'tamr_client' package is in BETA, but you do not have the '{beta_flag_name}' environment variable set to '1'." - f"\nSet '{beta_flag_name}=1' to opt-in to BETA features." + f"ERROR: 'tamr_client' package is in BETA, but you do not have the '{beta_flag_name}' environment variable set to '1'." + "\n\nHINT: Use 'tamr_unify_client' package instead for non-BETA features" + f"\nHINT: Set '{beta_flag_name}=1' to opt-in to BETA features." "\n\nWARNING: Do not rely on BETA features in production workflows." "\nTamr will not offer support for BETA features." ) From 95f59a9a58b5d2c2b085bf25d59eb0a9ace1eb31 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 19 Feb 2020 21:10:18 -0500 Subject: [PATCH 274/632] explicitly say that TAMR_CLIENT_BETA is an environment variable --- tamr_client/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 3f23e701..39b37e95 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -15,7 +15,7 @@ msg = ( f"ERROR: 'tamr_client' package is in BETA, but you do not have the '{beta_flag_name}' environment variable set to '1'." "\n\nHINT: Use 'tamr_unify_client' package instead for non-BETA features" - f"\nHINT: Set '{beta_flag_name}=1' to opt-in to BETA features." + f"\nHINT: Set environment variable '{beta_flag_name}=1' to opt-in to BETA features." "\n\nWARNING: Do not rely on BETA features in production workflows." "\nTamr will not offer support for BETA features." ) From 397be3baa126600596e184524513d2fbb0686d24 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 25 Feb 2020 12:17:01 -0500 Subject: [PATCH 275/632] fix BETA wording BETA features *are* supported, in a limited capacity. See https://datatamr.atlassian.net/wiki/spaces/PM/pages/891289994/Feature+Release+Statuses --- docs/beta.md | 2 +- tamr_client/__init__.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/beta.md b/docs/beta.md index d15c3d14..94ccc031 100644 --- a/docs/beta.md +++ b/docs/beta.md @@ -1,7 +1,7 @@ # BETA **WARNING**: Do not rely on BETA features in production workflows. - Tamr will not offer support for BETA features. + Support from Tamr may be limited. ## Reference diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 39b37e95..38069d78 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -7,17 +7,17 @@ import os import sys -beta_flag_name = "TAMR_CLIENT_BETA" -beta_flag_value = "1" -beta_flag = os.environ.get(beta_flag_name) +beta_flag = "TAMR_CLIENT_BETA" +beta_enabled = "1" +beta = os.environ.get(beta_flag) -if beta_flag != beta_flag_value: +if beta != beta_enabled: msg = ( - f"ERROR: 'tamr_client' package is in BETA, but you do not have the '{beta_flag_name}' environment variable set to '1'." + f"ERROR: 'tamr_client' package is in BETA, but you do not have the '{beta_flag}' environment variable set to '1'." "\n\nHINT: Use 'tamr_unify_client' package instead for non-BETA features" - f"\nHINT: Set environment variable '{beta_flag_name}=1' to opt-in to BETA features." + f"\nHINT: Set environment variable '{beta_flag}=1' to opt-in to BETA features." "\n\nWARNING: Do not rely on BETA features in production workflows." - "\nTamr will not offer support for BETA features." + " Support from Tamr may be limited." ) print(msg) sys.exit(1) From ebb08d41e7a406e82a3c35422f3fa99f3b652e53 Mon Sep 17 00:00:00 2001 From: abafzal Date: Fri, 20 Mar 2020 15:28:45 -0400 Subject: [PATCH 276/632] added dtype=str when creating dataframe in pandas --- docs/user-guide/spec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user-guide/spec.md b/docs/user-guide/spec.md index 85d31b60..7b738057 100644 --- a/docs/user-guide/spec.md +++ b/docs/user-guide/spec.md @@ -59,8 +59,8 @@ This makes it easy to create a Tamr dataset from a CSV: ```python import pandas as pd -df = pd.read_csv("my_data.csv") -dataset = tamr.datasets.create_from_dataframe(df, "primary key name", "My Data") +df = pd.read_csv("my_data.csv", dtype=str) +dataset = tamr.datasets.create_from_dataframe(df, primary_key_name="primary key name", dataset_name="My Data") ``` This will create a dataset called "My Data" with the specified primary key, an attribute From 39b50e467698f6c796c1371a5ad5c0cc71c462ea Mon Sep 17 00:00:00 2001 From: abafzal Date: Fri, 20 Mar 2020 15:39:15 -0400 Subject: [PATCH 277/632] changelog updates for PR #343 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e49b0e2..1dbd0afc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ **BUG FIXES** - Links from our docs to the `requests` docs were outdated. Links have been updated to point to the new `requests` docs URL. + - [#323](https://github.com/Datatamer/tamr-client/issues/323) ## 0.10.0 **BREAKING CHANGES** From edf05e21f8daf8a26e07463177d0a6d83eb3da7a Mon Sep 17 00:00:00 2001 From: Afsana Afzal Date: Mon, 23 Mar 2020 13:33:14 -0400 Subject: [PATCH 278/632] Update CHANGELOG.md Co-Authored-By: Pedro Cattori --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dbd0afc..94f824c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,7 @@ **BUG FIXES** - Links from our docs to the `requests` docs were outdated. Links have been updated to point to the new `requests` docs URL. - - [#323](https://github.com/Datatamer/tamr-client/issues/323) + - [#323](https://github.com/Datatamer/tamr-client/issues/323) Documentation for setting `dtype=str` before calling `client.datasets.create_from_dataframe` ## 0.10.0 **BREAKING CHANGES** From 123db1c955d144b831a83ecfb4c7ee9326c8b11e Mon Sep 17 00:00:00 2001 From: abafzal Date: Mon, 23 Mar 2020 13:36:39 -0400 Subject: [PATCH 279/632] inline comment --- docs/user-guide/spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-guide/spec.md b/docs/user-guide/spec.md index 7b738057..dfc03bc6 100644 --- a/docs/user-guide/spec.md +++ b/docs/user-guide/spec.md @@ -59,7 +59,7 @@ This makes it easy to create a Tamr dataset from a CSV: ```python import pandas as pd -df = pd.read_csv("my_data.csv", dtype=str) +df = pd.read_csv("my_data.csv", dtype=str) # string is the recommended data type dataset = tamr.datasets.create_from_dataframe(df, primary_key_name="primary key name", dataset_name="My Data") ``` From 4051f87b4714204880d4f19c34d761d9e6e4924e Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 20 Mar 2020 16:36:05 -0400 Subject: [PATCH 280/632] Moved dataset.attributes -> attribute.from_dataset_all Removes cyclic dependency between dataset and attribute Adhere to `from_*` convention across codebase --- CHANGELOG.md | 4 ++-- docs/beta/attributes/attribute.rst | 1 + docs/beta/datasets/dataset.rst | 1 - tamr_client/attributes/attribute.py | 27 ++++++++++++++++++++++++++- tamr_client/datasets/dataset.py | 27 +-------------------------- tests/attributes/test_attribute.py | 20 ++++++++++++++++++++ tests/datasets/test_dataset.py | 22 ---------------------- 7 files changed, 50 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94f824c7..16d1ee73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ - BETA: New attributes package! - `tc.attribute` module - `tc.Attribute` type - - functions: `from_resource_id`, `to_json`, `create`, `update`, `delete` + - functions: `from_resource_id`, `from_dataset_all`, `to_json`, `create`, `update`, `delete` - `tc.attribute_type` module - `tc.AttributeType` for type annotations - Primitive Types: `BOOLEAN`, `DOUBLE`, `INT`, `LONG`, `STRING` @@ -18,7 +18,7 @@ - BETA: New datasets package! - `tc.dataset` module - `tc.Dataset` type - - functions: `from_resource_id`, `attributes` + - functions: `from_resource_id` - BETA: New `tc.instance` module! - `tc.Instance` type - functions: `tc.instance.from_auth` diff --git a/docs/beta/attributes/attribute.rst b/docs/beta/attributes/attribute.rst index d12b4b32..09a039bb 100644 --- a/docs/beta/attributes/attribute.rst +++ b/docs/beta/attributes/attribute.rst @@ -4,6 +4,7 @@ Attribute .. autoclass:: tamr_client.Attribute .. autofunction:: tamr_client.attribute.from_resource_id +.. autofunction:: tamr_client.attribute.from_dataset_all .. autofunction:: tamr_client.attribute.to_json .. autofunction:: tamr_client.attribute.create .. autofunction:: tamr_client.attribute.update diff --git a/docs/beta/datasets/dataset.rst b/docs/beta/datasets/dataset.rst index 89ef25f4..62724246 100644 --- a/docs/beta/datasets/dataset.rst +++ b/docs/beta/datasets/dataset.rst @@ -4,7 +4,6 @@ Dataset .. autoclass:: tamr_client.Dataset .. autofunction:: tamr_client.dataset.from_resource_id -.. autofunction:: tamr_client.dataset.attributes Exceptions ---------- diff --git a/tamr_client/attributes/attribute.py b/tamr_client/attributes/attribute.py index 413de3ef..c8fe29e3 100644 --- a/tamr_client/attributes/attribute.py +++ b/tamr_client/attributes/attribute.py @@ -3,7 +3,7 @@ """ from copy import deepcopy from dataclasses import dataclass, field, replace -from typing import Optional +from typing import Optional, Tuple import tamr_client as tc from tamr_client.types import JsonDict @@ -124,6 +124,31 @@ def _from_json(url: tc.URL, data: JsonDict) -> Attribute: ) +def from_dataset_all(session: tc.Session, dataset: tc.Dataset) -> Tuple[Attribute]: + """Get all attributes from a dataset + + Args: + dataset: Dataset containing the desired attributes + + Returns: + The attributes for the specified dataset + + Raises: + requests.HTTPError: If an HTTP error is encountered. + """ + attrs_url = replace(dataset.url, path=dataset.url.path + "/attributes") + r = session.get(str(attrs_url)) + attrs_json = tc.response.successful(r).json() + + attrs = [] + for attr_json in attrs_json: + id = attr_json["name"] + attr_url = replace(attrs_url, path=attrs_url.path + f"/{id}") + attr = _from_json(attr_url, attr_json) + attrs.append(attr) + return tuple(attrs) + + def to_json(attr: Attribute) -> JsonDict: """Serialize attribute into JSON diff --git a/tamr_client/datasets/dataset.py b/tamr_client/datasets/dataset.py index f83b6c37..ae26c0e0 100644 --- a/tamr_client/datasets/dataset.py +++ b/tamr_client/datasets/dataset.py @@ -2,7 +2,7 @@ See https://docs.tamr.com/reference/dataset-models """ from copy import deepcopy -from dataclasses import dataclass, replace +from dataclasses import dataclass from typing import Optional, Tuple import tamr_client as tc @@ -86,28 +86,3 @@ def _from_json(url: tc.URL, data: JsonDict) -> Dataset: description=cp.get("description"), key_attribute_names=tuple(cp["keyAttributeNames"]), ) - - -def attributes(session: tc.Session, dataset: Dataset) -> Tuple["tc.Attribute", ...]: - """Get attributes for this dataset - - Args: - dataset: Dataset containing the desired attributes - - Returns: - The attributes for the specified dataset - - Raises: - requests.HTTPError: If an HTTP error is encountered. - """ - attrs_url = replace(dataset.url, path=dataset.url.path + "/attributes") - r = session.get(str(attrs_url)) - attrs_json = tc.response.successful(r).json() - - attrs = [] - for attr_json in attrs_json: - id = attr_json["name"] - attr_url = replace(attrs_url, path=attrs_url.path + f"/{id}") - attr = tc.attribute._from_json(attr_url, attr_json) - attrs.append(attr) - return tuple(attrs) diff --git a/tests/attributes/test_attribute.py b/tests/attributes/test_attribute.py index eb852574..185e7bae 100644 --- a/tests/attributes/test_attribute.py +++ b/tests/attributes/test_attribute.py @@ -123,6 +123,26 @@ def test_create_reserved_attribute_name(): tc.attribute.create(s, dataset, name="clusterId", is_nullable=False) +@responses.activate +def test_from_dataset_all(): + s = utils.session() + dataset = utils.dataset() + + attrs_url = replace(dataset.url, path=dataset.url.path + "/attributes") + attrs_json = utils.load_json("attributes.json") + responses.add(responses.GET, str(attrs_url), json=attrs_json, status=204) + + attrs = tc.attribute.from_dataset_all(s, dataset) + + row_num = attrs[0] + assert row_num.name == "RowNum" + assert isinstance(row_num.type, tc.attribute_type.String) + + geom = attrs[1] + assert geom.name == "geom" + assert isinstance(geom.type, tc.attribute_type.Record) + + @responses.activate def test_create_attribute_exists(): s = utils.session() diff --git a/tests/datasets/test_dataset.py b/tests/datasets/test_dataset.py index 27455d9d..21cfb5f1 100644 --- a/tests/datasets/test_dataset.py +++ b/tests/datasets/test_dataset.py @@ -1,5 +1,3 @@ -from dataclasses import replace - import pytest import responses @@ -32,23 +30,3 @@ def test_from_resource_id_dataset_not_found(): with pytest.raises(tc.DatasetNotFound): tc.dataset.from_resource_id(s, instance, "1") - - -@responses.activate -def test_attributes(): - s = utils.session() - dataset = utils.dataset() - - attrs_url = replace(dataset.url, path=dataset.url.path + "/attributes") - attrs_json = utils.load_json("attributes.json") - responses.add(responses.GET, str(attrs_url), json=attrs_json, status=204) - - attrs = tc.dataset.attributes(s, dataset) - - row_num = attrs[0] - assert row_num.name == "RowNum" - assert isinstance(row_num.type, tc.attribute_type.String) - - geom = attrs[1] - assert geom.name == "geom" - assert isinstance(geom.type, tc.attribute_type.Record) From 248cb6c4df798b1f9fb7cc6a3ab126e20361c099 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 20 Mar 2020 19:10:29 -0400 Subject: [PATCH 281/632] namespace tamr_client tests Before it was confusing/unclear that some directories were for testing the tamr_client package and others for tamr_unify_client package. --- tests/{ => tamr_client}/attributes/test_attribute.py | 2 +- tests/{ => tamr_client}/attributes/test_attribute_type.py | 6 +++--- tests/{ => tamr_client}/data/attribute.json | 0 tests/{ => tamr_client}/data/attributes.json | 0 tests/{ => tamr_client}/data/dataset.json | 0 tests/{ => tamr_client}/data/updated_attribute.json | 0 tests/{ => tamr_client}/datasets/test_dataset.py | 2 +- tests/{ => tamr_client}/utils.py | 0 8 files changed, 5 insertions(+), 5 deletions(-) rename tests/{ => tamr_client}/attributes/test_attribute.py (99%) rename tests/{ => tamr_client}/attributes/test_attribute_type.py (90%) rename tests/{ => tamr_client}/data/attribute.json (100%) rename tests/{ => tamr_client}/data/attributes.json (100%) rename tests/{ => tamr_client}/data/dataset.json (100%) rename tests/{ => tamr_client}/data/updated_attribute.json (100%) rename tests/{ => tamr_client}/datasets/test_dataset.py (95%) rename tests/{ => tamr_client}/utils.py (100%) diff --git a/tests/attributes/test_attribute.py b/tests/tamr_client/attributes/test_attribute.py similarity index 99% rename from tests/attributes/test_attribute.py rename to tests/tamr_client/attributes/test_attribute.py index 185e7bae..e507eb00 100644 --- a/tests/attributes/test_attribute.py +++ b/tests/tamr_client/attributes/test_attribute.py @@ -4,7 +4,7 @@ import responses import tamr_client as tc -import tests.utils as utils +import tests.tamr_client.utils as utils def test_from_json(): diff --git a/tests/attributes/test_attribute_type.py b/tests/tamr_client/attributes/test_attribute_type.py similarity index 90% rename from tests/attributes/test_attribute_type.py rename to tests/tamr_client/attributes/test_attribute_type.py index 793f3bb4..39a85322 100644 --- a/tests/attributes/test_attribute_type.py +++ b/tests/tamr_client/attributes/test_attribute_type.py @@ -1,9 +1,9 @@ import tamr_client as tc -import tests.utils +import tests.tamr_client.utils as utils def test_from_json(): - geom_json = tests.utils.load_json("attributes.json")[1] + geom_json = utils.load_json("attributes.json")[1] geom_type = tc.attribute_type.from_json(geom_json["type"]) assert isinstance(geom_type, tc.attribute_type.Record) @@ -33,7 +33,7 @@ def test_from_json(): def test_json(): - attrs_json = tests.utils.load_json("attributes.json") + attrs_json = utils.load_json("attributes.json") for attr_json in attrs_json: attr_type_json = attr_json["type"] attr_type = tc.attribute_type.from_json(attr_type_json) diff --git a/tests/data/attribute.json b/tests/tamr_client/data/attribute.json similarity index 100% rename from tests/data/attribute.json rename to tests/tamr_client/data/attribute.json diff --git a/tests/data/attributes.json b/tests/tamr_client/data/attributes.json similarity index 100% rename from tests/data/attributes.json rename to tests/tamr_client/data/attributes.json diff --git a/tests/data/dataset.json b/tests/tamr_client/data/dataset.json similarity index 100% rename from tests/data/dataset.json rename to tests/tamr_client/data/dataset.json diff --git a/tests/data/updated_attribute.json b/tests/tamr_client/data/updated_attribute.json similarity index 100% rename from tests/data/updated_attribute.json rename to tests/tamr_client/data/updated_attribute.json diff --git a/tests/datasets/test_dataset.py b/tests/tamr_client/datasets/test_dataset.py similarity index 95% rename from tests/datasets/test_dataset.py rename to tests/tamr_client/datasets/test_dataset.py index 21cfb5f1..1fd0103d 100644 --- a/tests/datasets/test_dataset.py +++ b/tests/tamr_client/datasets/test_dataset.py @@ -2,7 +2,7 @@ import responses import tamr_client as tc -import tests.utils as utils +import tests.tamr_client.utils as utils @responses.activate diff --git a/tests/utils.py b/tests/tamr_client/utils.py similarity index 100% rename from tests/utils.py rename to tests/tamr_client/utils.py From fb96fe44084e54fcdd5107ecd11034c5b7a061f4 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 20 Mar 2020 20:01:34 -0400 Subject: [PATCH 282/632] Upgrade mypy from 0.761 to 0.770 --- poetry.lock | 32 ++++++++++++++++---------------- pyproject.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/poetry.lock b/poetry.lock index b1028dd3..9655d37c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -253,7 +253,7 @@ description = "Optional static typing for Python" name = "mypy" optional = false python-versions = ">=3.5" -version = "0.761" +version = "0.770" [package.dependencies] mypy-extensions = ">=0.4.3,<0.5.0" @@ -663,7 +663,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["pathlib2", "contextlib2", "unittest2"] [metadata] -content-hash = "780d46feedafa3cebf4fcaf0b4cf73f60b05f82cbc40764e0cf64f2138e48e4b" +content-hash = "bd87d3d5f8ec082962b9a470e5fd95c70886d5a3435e320fe806666e696d5396" python-versions = "^3.6" [metadata.files] @@ -795,20 +795,20 @@ more-itertools = [ {file = "more_itertools-8.0.2-py3-none-any.whl", hash = "sha256:c833ef592a0324bcc6a60e48440da07645063c453880c9477ceb22490aec1564"}, ] mypy = [ - {file = "mypy-0.761-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:7f672d02fffcbace4db2b05369142e0506cdcde20cea0e07c7c2171c4fd11dd6"}, - {file = "mypy-0.761-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:87c556fb85d709dacd4b4cb6167eecc5bbb4f0a9864b69136a0d4640fdc76a36"}, - {file = "mypy-0.761-cp35-cp35m-win_amd64.whl", hash = "sha256:c6d27bd20c3ba60d5b02f20bd28e20091d6286a699174dfad515636cb09b5a72"}, - {file = "mypy-0.761-cp36-cp36m-macosx_10_6_x86_64.whl", hash = "sha256:4b9365ade157794cef9685791032521233729cb00ce76b0ddc78749abea463d2"}, - {file = "mypy-0.761-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:634aef60b4ff0f650d3e59d4374626ca6153fcaff96ec075b215b568e6ee3cb0"}, - {file = "mypy-0.761-cp36-cp36m-win_amd64.whl", hash = "sha256:53ea810ae3f83f9c9b452582261ea859828a9ed666f2e1ca840300b69322c474"}, - {file = "mypy-0.761-cp37-cp37m-macosx_10_6_x86_64.whl", hash = "sha256:0a9a45157e532da06fe56adcfef8a74629566b607fa2c1ac0122d1ff995c748a"}, - {file = "mypy-0.761-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:7eadc91af8270455e0d73565b8964da1642fe226665dd5c9560067cd64d56749"}, - {file = "mypy-0.761-cp37-cp37m-win_amd64.whl", hash = "sha256:e2bb577d10d09a2d8822a042a23b8d62bc3b269667c9eb8e60a6edfa000211b1"}, - {file = "mypy-0.761-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c35cae79ceb20d47facfad51f952df16c2ae9f45db6cb38405a3da1cf8fc0a7"}, - {file = "mypy-0.761-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:f97a605d7c8bc2c6d1172c2f0d5a65b24142e11a58de689046e62c2d632ca8c1"}, - {file = "mypy-0.761-cp38-cp38-win_amd64.whl", hash = "sha256:a6bd44efee4dc8c3324c13785a9dc3519b3ee3a92cada42d2b57762b7053b49b"}, - {file = "mypy-0.761-py3-none-any.whl", hash = "sha256:7e396ce53cacd5596ff6d191b47ab0ea18f8e0ec04e15d69728d530e86d4c217"}, - {file = "mypy-0.761.tar.gz", hash = "sha256:85baab8d74ec601e86134afe2bcccd87820f79d2f8d5798c889507d1088287bf"}, + {file = "mypy-0.770-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:a34b577cdf6313bf24755f7a0e3f3c326d5c1f4fe7422d1d06498eb25ad0c600"}, + {file = "mypy-0.770-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:86c857510a9b7c3104cf4cde1568f4921762c8f9842e987bc03ed4f160925754"}, + {file = "mypy-0.770-cp35-cp35m-win_amd64.whl", hash = "sha256:a8ffcd53cb5dfc131850851cc09f1c44689c2812d0beb954d8138d4f5fc17f65"}, + {file = "mypy-0.770-cp36-cp36m-macosx_10_6_x86_64.whl", hash = "sha256:7687f6455ec3ed7649d1ae574136835a4272b65b3ddcf01ab8704ac65616c5ce"}, + {file = "mypy-0.770-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:3beff56b453b6ef94ecb2996bea101a08f1f8a9771d3cbf4988a61e4d9973761"}, + {file = "mypy-0.770-cp36-cp36m-win_amd64.whl", hash = "sha256:15b948e1302682e3682f11f50208b726a246ab4e6c1b39f9264a8796bb416aa2"}, + {file = "mypy-0.770-cp37-cp37m-macosx_10_6_x86_64.whl", hash = "sha256:b90928f2d9eb2f33162405f32dde9f6dcead63a0971ca8a1b50eb4ca3e35ceb8"}, + {file = "mypy-0.770-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:c56ffe22faa2e51054c5f7a3bc70a370939c2ed4de308c690e7949230c995913"}, + {file = "mypy-0.770-cp37-cp37m-win_amd64.whl", hash = "sha256:8dfb69fbf9f3aeed18afffb15e319ca7f8da9642336348ddd6cab2713ddcf8f9"}, + {file = "mypy-0.770-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:219a3116ecd015f8dca7b5d2c366c973509dfb9a8fc97ef044a36e3da66144a1"}, + {file = "mypy-0.770-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7ec45a70d40ede1ec7ad7f95b3c94c9cf4c186a32f6bacb1795b60abd2f9ef27"}, + {file = "mypy-0.770-cp38-cp38-win_amd64.whl", hash = "sha256:f91c7ae919bbc3f96cd5e5b2e786b2b108343d1d7972ea130f7de27fdd547cf3"}, + {file = "mypy-0.770-py3-none-any.whl", hash = "sha256:3b1fc683fb204c6b4403a1ef23f0b1fac8e4477091585e0c8c54cbdf7d7bb164"}, + {file = "mypy-0.770.tar.gz", hash = "sha256:8a627507ef9b307b46a1fea9513d5c98680ba09591253082b4c48697ba05a4ae"}, ] mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, diff --git a/pyproject.toml b/pyproject.toml index 93eafa9a..48330e10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ sphinx-autodoc-typehints = "^1.8" pandas = "^0.25.3" pytest = "^5.3.2" invoke = "^1.4.0" -mypy = "^0.761" +mypy = "^0.770" [build-system] requires = ["poetry>=1.0"] From ee564d9dc51bddf4ae9b7765ef23301317e864e3 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 20 Mar 2020 20:02:09 -0400 Subject: [PATCH 283/632] fix(typecheck): Typecheck packages and tests differently Packages should be typechecked with `--package` flag. Previously, a typecheck error in attribute.py went unnoticed by mypy. Additionally, for `--package` to work, __init__.py files are required (at least for now, see https://github.com/python/mypy/issues/5759 ). --- tamr_client/attributes/__init__.py | 2 ++ tamr_client/datasets/__init__.py | 2 ++ tasks.py | 23 +++++++++++++---------- 3 files changed, 17 insertions(+), 10 deletions(-) create mode 100644 tamr_client/attributes/__init__.py create mode 100644 tamr_client/datasets/__init__.py diff --git a/tamr_client/attributes/__init__.py b/tamr_client/attributes/__init__.py new file mode 100644 index 00000000..b2df60f9 --- /dev/null +++ b/tamr_client/attributes/__init__.py @@ -0,0 +1,2 @@ +# This __init__.py file is necessary for `mypy --package` +# See https://github.com/python/mypy/issues/5759 diff --git a/tamr_client/datasets/__init__.py b/tamr_client/datasets/__init__.py new file mode 100644 index 00000000..b2df60f9 --- /dev/null +++ b/tamr_client/datasets/__init__.py @@ -0,0 +1,2 @@ +# This __init__.py file is necessary for `mypy --package` +# See https://github.com/python/mypy/issues/5759 diff --git a/tasks.py b/tasks.py index f5f1cbe9..d74c221f 100644 --- a/tasks.py +++ b/tasks.py @@ -5,6 +5,12 @@ beta = "TAMR_CLIENT_BETA=1" +def _find_packages(path: Path): + for pkg in path.iterdir(): + if pkg.is_dir() and len(list(pkg.glob("**/*.py"))) >= 1: + yield pkg + + @task def lint(c): c.run("poetry run flake8 .", echo=True, pty=True) @@ -19,17 +25,14 @@ def format(c, fix=False): @task def typecheck(c, warn=True): repo = Path(".") + tc = repo / "tamr_client" - tests = repo / "tests" - pkgs = [ - tc, - tc / "attributes", - tests / "attributes", - tc / "datasets", - tests / "datasets", - ] - for pkg in pkgs: - c.run(f"poetry run mypy {str(pkg)}", echo=True, pty=True, warn=warn) + c.run(f"poetry run mypy --package {tc}", echo=True, pty=True, warn=warn) + + tc_tests = " ".join( + str(x) for x in (repo / "tests" / "tamr_client").glob("**/*.py") + ) + c.run(f"poetry run mypy {tc_tests}", echo=True, pty=True, warn=warn) @task From 15ed61ca8fb646c6224bf47d71d86bd8fe9add44 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 20 Mar 2020 20:05:18 -0400 Subject: [PATCH 284/632] fix(typecheck): attribute.from_dataset_all ...should return a tuple of 1 *or more* attributes. The correct syntax for this is `Tuple[, ...]` not `Tuple[]` --- tamr_client/attributes/attribute.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_client/attributes/attribute.py b/tamr_client/attributes/attribute.py index c8fe29e3..31a243be 100644 --- a/tamr_client/attributes/attribute.py +++ b/tamr_client/attributes/attribute.py @@ -124,7 +124,7 @@ def _from_json(url: tc.URL, data: JsonDict) -> Attribute: ) -def from_dataset_all(session: tc.Session, dataset: tc.Dataset) -> Tuple[Attribute]: +def from_dataset_all(session: tc.Session, dataset: tc.Dataset) -> Tuple[Attribute, ...]: """Get all attributes from a dataset Args: From b0b4ebc5b035e25a8216ad4489f22bbbe2e025f6 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 20 Mar 2020 20:16:33 -0400 Subject: [PATCH 285/632] BETA check in its own module Users autocompleting `tc.` won't get results like `tc.os` like before since that `os` import was for the BETA check that is now in a separate module. Also, reorder tamr_client/__init__.py with comment headers for clarity. --- tamr_client/__init__.py | 35 +++++++++++------------------------ tamr_client/beta.py | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 24 deletions(-) create mode 100644 tamr_client/beta.py diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 38069d78..2b8f9119 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -1,33 +1,23 @@ # flake8: noqa +# BETA check ############ -# BETA start -############ - -import os -import sys -beta_flag = "TAMR_CLIENT_BETA" -beta_enabled = "1" -beta = os.environ.get(beta_flag) +import tamr_client.beta as beta -if beta != beta_enabled: - msg = ( - f"ERROR: 'tamr_client' package is in BETA, but you do not have the '{beta_flag}' environment variable set to '1'." - "\n\nHINT: Use 'tamr_unify_client' package instead for non-BETA features" - f"\nHINT: Set environment variable '{beta_flag}=1' to opt-in to BETA features." - "\n\nWARNING: Do not rely on BETA features in production workflows." - " Support from Tamr may be limited." - ) - print(msg) - sys.exit(1) +beta._check() -########## -# BETA end -########## +# Logging +######### import logging +# https://docs.python-guide.org/writing/logging/#logging-in-a-library +logging.getLogger(__name__).addHandler(logging.NullHandler()) + +# Import shortcuts +################## + # utilities import tamr_client.response as response @@ -66,6 +56,3 @@ AttributeNotFound, ) import tamr_client.attributes.attribute as attribute - -# https://docs.python-guide.org/writing/logging/#logging-in-a-library -logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/tamr_client/beta.py b/tamr_client/beta.py new file mode 100644 index 00000000..cc04fcea --- /dev/null +++ b/tamr_client/beta.py @@ -0,0 +1,19 @@ +import os +import sys + + +def _check(): + beta_flag = "TAMR_CLIENT_BETA" + beta_enabled = "1" + beta = os.environ.get(beta_flag) + + if beta != beta_enabled: + msg = ( + f"ERROR: 'tamr_client' package is in BETA, but you do not have the '{beta_flag}' environment variable set to '1'." + "\n\nHINT: Use 'tamr_unify_client' package instead for non-BETA features" + f"\nHINT: Set environment variable '{beta_flag}=1' to opt-in to BETA features." + "\n\nWARNING: Do not rely on BETA features in production workflows." + " Support from Tamr may be limited." + ) + print(msg) + sys.exit(1) From 7ad4c7feb0c72ed0d8b9d93cb75d5f2f966dbcfb Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 20 Mar 2020 20:50:51 -0400 Subject: [PATCH 286/632] response.ndjson utility ...for streaming newline-delimited JSON body from a response --- CHANGELOG.md | 2 ++ docs/beta.md | 1 + docs/beta/response.rst | 7 +++++++ stubs/responses.pyi | 1 + tamr_client/response.py | 30 ++++++++++++++++++++++++++++++ tests/tamr_client/test_response.py | 24 ++++++++++++++++++++++++ 6 files changed, 65 insertions(+) create mode 100644 docs/beta/response.rst create mode 100644 tests/tamr_client/test_response.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 16d1ee73..0dd9e9c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,8 @@ - functions: `tc.session.from_auth` - `tc.url` module - `tc.URL` type + - `tc.response` module + - functions: `successful`, `ndjson` **BUG FIXES** - Links from our docs to the `requests` docs were outdated. Links have been updated to point to the new `requests` docs URL. diff --git a/docs/beta.md b/docs/beta.md index 94ccc031..b7d7d86f 100644 --- a/docs/beta.md +++ b/docs/beta.md @@ -9,4 +9,5 @@ * [Auth](beta/auth) * [Dataset](beta/datasets) * [Instance](beta/instance) + * [Response](beta/response) * [Session](beta/session) diff --git a/docs/beta/response.rst b/docs/beta/response.rst new file mode 100644 index 00000000..807b2bb5 --- /dev/null +++ b/docs/beta/response.rst @@ -0,0 +1,7 @@ +Response +======== + +Utilities for working with :class:`requests.Response` . + +.. autofunction:: tamr_client.response.successful +.. autofunction:: tamr_client.response.ndjson diff --git a/stubs/responses.pyi b/stubs/responses.pyi index c740ff6d..adb6753e 100644 --- a/stubs/responses.pyi +++ b/stubs/responses.pyi @@ -10,6 +10,7 @@ PUT: str def add( method: Optional[str] = None, url: Optional[str] = None, + body: Optional[str] = None, status: Optional[int] = None, json: Optional[JsonDict] = None, ): ... diff --git a/tamr_client/response.py b/tamr_client/response.py index f06aec3a..2d603480 100644 --- a/tamr_client/response.py +++ b/tamr_client/response.py @@ -1,7 +1,11 @@ +import json import logging +from typing import Iterator import requests +from tamr_client.types import JsonDict + logger = logging.getLogger(__name__) @@ -25,3 +29,29 @@ def successful(response: requests.Response) -> requests.Response: ) raise e return response + + +def ndjson(response: requests.Response, **kwargs) -> Iterator[JsonDict]: + """Stream newline-delimited JSON from the response body + + Analog to :func:`requests.Response.json` but for ``.ndjson``-formatted body. + + **Recommended**: For memory efficiency, use ``stream=True`` when sending the request corresponding to this response. + + Args: + response: Response whose body should be streamed as newline-delimited JSON. + **kwargs: Keyword arguments passed to underlying :func:`requests.Response.iter_lines` call. + + Returns + Each line of the response body, parsed as JSON + + Example: + >>> import tamr_client as tc + >>> s = tc.session.from_auth(...) + >>> r = s.get(..., stream=True) + >>> for data in tc.response.ndjson(r): + ... assert data['my key'] == 'my_value' + + """ + for line in response.iter_lines(**kwargs): + yield json.loads(line) diff --git a/tests/tamr_client/test_response.py b/tests/tamr_client/test_response.py new file mode 100644 index 00000000..14817e5c --- /dev/null +++ b/tests/tamr_client/test_response.py @@ -0,0 +1,24 @@ +import json + +import responses + +import tamr_client as tc +import tests.tamr_client.utils as utils + + +@responses.activate +def test_ndjson(): + s = utils.session() + + records = [{"a": 1}, {"b": 2}, {"c": 3}] + url = tc.URL(path="datasets/1/records") + responses.add( + responses.GET, str(url), body="\n".join(json.dumps(x) for x in records) + ) + + r = s.get(str(url)) + + ndjson = list(tc.response.ndjson(r)) + assert len(ndjson) == 3 + for record in ndjson: + assert record in records From 9edab27a951ac61c5289045e6b15aad8bb4a8478 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Tue, 24 Mar 2020 18:03:22 -0400 Subject: [PATCH 287/632] Added project lookup by-name() method, as well as appropriate tests. --- tamr_unify_client/project/collection.py | 15 ++++++++ tests/unit/test_project_by_name.py | 48 +++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 tests/unit/test_project_by_name.py diff --git a/tamr_unify_client/project/collection.py b/tamr_unify_client/project/collection.py index e701debb..359b7649 100644 --- a/tamr_unify_client/project/collection.py +++ b/tamr_unify_client/project/collection.py @@ -62,6 +62,21 @@ def stream(self): """ return super().stream(Project) + def by_name(self, project_name: str) -> Project: + """Get project by name + + Fetches a specific project in this collection by exact-match on name. + + Args: + project_name: Name of the desired project. + Raises: + KeyError: If no project with specified name was found. + """ + for project in self: + if project.name == project_name: + return project + raise KeyError(f"No project found with name: {project_name}") + def create(self, creation_spec): """ Create a Project in Tamr diff --git a/tests/unit/test_project_by_name.py b/tests/unit/test_project_by_name.py new file mode 100644 index 00000000..c3c49056 --- /dev/null +++ b/tests/unit/test_project_by_name.py @@ -0,0 +1,48 @@ +import pytest +import responses + +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth + +project_json = [ + { + "id": "unify://unified-data/v1/projects/1", + "name": "project 1 name", + "description": "project 1 description", + "type": "DEDUP", + "unifiedDatasetName": "project 1 name_unified_dataset", + "created": { + "username": "admin", + "time": "2020-03-24T12:43:57.087Z", + "version": "project 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2020-03-24T12:43:59.564Z", + "version": "project 1 modified version" + }, + "relativeId": "projects/1", + "externalId": "number 1" + } +] + +project_name = "project 1 name" +projects_url = f"http://localhost:9100/api/versioned/v1/projects" + + +@responses.activate +def test_project_by_name__raises_when_not_found(): + responses.add(responses.GET, projects_url, json=[]) + auth = UsernamePasswordAuth("username", "password") + tamr = Client(auth) + with pytest.raises(KeyError): + tamr.projects.by_name(project_name) + + +@responses.activate +def test_dataset_by_name_succeeds(): + responses.add(responses.GET, projects_url, json=project_json) + auth = UsernamePasswordAuth("username", "password") + tamr = Client(auth) + actual_project = tamr.projects.by_name(project_name) + assert actual_project._data == project_json[0] From 5f131f8cbd1c869d746511b0f34e7ca903b69bb9 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Tue, 24 Mar 2020 18:39:37 -0400 Subject: [PATCH 288/632] Combined the Project tests and fixed the formatting. --- tests/unit/test_project.py | 18 +++++++++++ tests/unit/test_project_by_name.py | 48 ------------------------------ 2 files changed, 18 insertions(+), 48 deletions(-) delete mode 100644 tests/unit/test_project_by_name.py diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index cfb9485a..40074d4b 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -66,6 +66,22 @@ def test_project_by_external_id_succeeds(self): actual_project = self.tamr.projects.by_external_id(self.project_external_id) self.assertEqual(self.project_json[0], actual_project._data) + @responses.activate + def test_project_by_name__raises_when_not_found(): + responses.add(responses.GET, projects_list_url, json=[]) + auth = UsernamePasswordAuth("username", "password") + tamr = Client(auth) + with pytest.raises(KeyError): + tamr.projects.by_name(project_name) + + @responses.activate + def test_dataset_by_name_succeeds(): + responses.add(responses.GET, projects_list_url, json=project_json) + auth = UsernamePasswordAuth("username", "password") + tamr = Client(auth) + actual_project = tamr.projects.by_name(project_name) + assert actual_project._data == project_json[0] + @responses.activate def test_project_attributes_get(self): responses.add(responses.GET, self.projects_url, json=self.project_json) @@ -216,8 +232,10 @@ def create_callback(request, snoop): "relativeId": "projects/1", } ] + project_name = "project 1 name" project_external_id = "project 1 external ID" projects_url = f"http://localhost:9100/api/versioned/v1/projects?filter=externalId=={project_external_id}" + project_list_url = "http://localhost:9100/api/versioned/v1/projects" post_input_datasets_json = [] input_datasets_url = ( f"http://localhost:9100/api/versioned/v1/projects/1/inputDatasets" diff --git a/tests/unit/test_project_by_name.py b/tests/unit/test_project_by_name.py deleted file mode 100644 index c3c49056..00000000 --- a/tests/unit/test_project_by_name.py +++ /dev/null @@ -1,48 +0,0 @@ -import pytest -import responses - -from tamr_unify_client import Client -from tamr_unify_client.auth import UsernamePasswordAuth - -project_json = [ - { - "id": "unify://unified-data/v1/projects/1", - "name": "project 1 name", - "description": "project 1 description", - "type": "DEDUP", - "unifiedDatasetName": "project 1 name_unified_dataset", - "created": { - "username": "admin", - "time": "2020-03-24T12:43:57.087Z", - "version": "project 1 created version" - }, - "lastModified": { - "username": "admin", - "time": "2020-03-24T12:43:59.564Z", - "version": "project 1 modified version" - }, - "relativeId": "projects/1", - "externalId": "number 1" - } -] - -project_name = "project 1 name" -projects_url = f"http://localhost:9100/api/versioned/v1/projects" - - -@responses.activate -def test_project_by_name__raises_when_not_found(): - responses.add(responses.GET, projects_url, json=[]) - auth = UsernamePasswordAuth("username", "password") - tamr = Client(auth) - with pytest.raises(KeyError): - tamr.projects.by_name(project_name) - - -@responses.activate -def test_dataset_by_name_succeeds(): - responses.add(responses.GET, projects_url, json=project_json) - auth = UsernamePasswordAuth("username", "password") - tamr = Client(auth) - actual_project = tamr.projects.by_name(project_name) - assert actual_project._data == project_json[0] From 939a40297dc873387bfe463f49f2e640b754a547 Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Tue, 24 Mar 2020 18:55:13 -0400 Subject: [PATCH 289/632] fixed error in test_project.py --- tests/unit/test_project.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index 40074d4b..cc4c2747 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -68,7 +68,7 @@ def test_project_by_external_id_succeeds(self): @responses.activate def test_project_by_name__raises_when_not_found(): - responses.add(responses.GET, projects_list_url, json=[]) + responses.add(responses.GET, project_list_url, json=[]) auth = UsernamePasswordAuth("username", "password") tamr = Client(auth) with pytest.raises(KeyError): @@ -76,7 +76,7 @@ def test_project_by_name__raises_when_not_found(): @responses.activate def test_dataset_by_name_succeeds(): - responses.add(responses.GET, projects_list_url, json=project_json) + responses.add(responses.GET, project_list_url, json=project_json) auth = UsernamePasswordAuth("username", "password") tamr = Client(auth) actual_project = tamr.projects.by_name(project_name) From c00987c000a6b405d8fb48a09bdf206bf6eb574b Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Tue, 24 Mar 2020 19:01:41 -0400 Subject: [PATCH 290/632] my tests now conform to the class-structure of this file. --- tests/unit/test_project.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index cc4c2747..4c9dc8d4 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -68,19 +68,19 @@ def test_project_by_external_id_succeeds(self): @responses.activate def test_project_by_name__raises_when_not_found(): - responses.add(responses.GET, project_list_url, json=[]) + responses.add(responses.GET, self.project_list_url, json=[]) auth = UsernamePasswordAuth("username", "password") tamr = Client(auth) with pytest.raises(KeyError): - tamr.projects.by_name(project_name) + tamr.projects.by_name(self.project_name) @responses.activate def test_dataset_by_name_succeeds(): - responses.add(responses.GET, project_list_url, json=project_json) + responses.add(responses.GET, self.project_list_url, json=self.project_json) auth = UsernamePasswordAuth("username", "password") tamr = Client(auth) - actual_project = tamr.projects.by_name(project_name) - assert actual_project._data == project_json[0] + actual_project = tamr.projects.by_name(self.project_name) + assert actual_project._data == self.project_json[0] @responses.activate def test_project_attributes_get(self): From c300a46c49c3a8d7580b05ed6394530dfaf125b8 Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Tue, 24 Mar 2020 19:09:07 -0400 Subject: [PATCH 291/632] fixed more errors with regard to tests in classes --- tests/unit/test_project.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index 4c9dc8d4..e2e66d20 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -67,15 +67,15 @@ def test_project_by_external_id_succeeds(self): self.assertEqual(self.project_json[0], actual_project._data) @responses.activate - def test_project_by_name__raises_when_not_found(): + def test_project_by_name__raises_when_not_found(self): responses.add(responses.GET, self.project_list_url, json=[]) auth = UsernamePasswordAuth("username", "password") tamr = Client(auth) - with pytest.raises(KeyError): + with self.assertRaises(KeyError): tamr.projects.by_name(self.project_name) @responses.activate - def test_dataset_by_name_succeeds(): + def test_dataset_by_name_succeeds(self): responses.add(responses.GET, self.project_list_url, json=self.project_json) auth = UsernamePasswordAuth("username", "password") tamr = Client(auth) From db2549d8bb1230d8532420038269abf1563deeaa Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Wed, 25 Mar 2020 11:16:44 -0400 Subject: [PATCH 292/632] Update tests/unit/test_project.py Co-Authored-By: Pedro Cattori --- tests/unit/test_project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index e2e66d20..d0d309f1 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -75,7 +75,7 @@ def test_project_by_name__raises_when_not_found(self): tamr.projects.by_name(self.project_name) @responses.activate - def test_dataset_by_name_succeeds(self): + def test_dataset_by_name(self): responses.add(responses.GET, self.project_list_url, json=self.project_json) auth = UsernamePasswordAuth("username", "password") tamr = Client(auth) From 1898c97745ddeb5f417fef0302d0d6d368d00a03 Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Wed, 25 Mar 2020 11:29:03 -0400 Subject: [PATCH 293/632] Added by_name() example to docs. --- docs/user-guide/quickstart.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/user-guide/quickstart.md b/docs/user-guide/quickstart.md index 80abf623..88a73abb 100644 --- a/docs/user-guide/quickstart.md +++ b/docs/user-guide/quickstart.md @@ -51,6 +51,12 @@ E.g. To fetch the project with ID `'1'`: ```python project = tamr.projects.by_resource_id('1') ``` +Similarly, if you know the name of a specific resource, you can ask for it directly via the `by_name` methods exposed by collections. + +E.g. To fetch the project with name `'Number 1'`: +```python +project = tamr.projects.by_name('Number 1') +``` ## Resource relationships Related resources (like a project and its unified dataset) can be accessed through specific methods. From 15c586c04b1a87020dd2fa78e3ac32a5dac3b867 Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Wed, 25 Mar 2020 11:41:41 -0400 Subject: [PATCH 294/632] updated the Changelog.md to include projects.by_name() addition --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dd9e9c3..65f648a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ - `tc.URL` type - `tc.response` module - functions: `successful`, `ndjson` - + - [#35](https://github.com/Datatamer/tamr-client/issues/35) projects.by_name() functionality added. Can now fetch a project by its name. **BUG FIXES** - Links from our docs to the `requests` docs were outdated. Links have been updated to point to the new `requests` docs URL. - [#323](https://github.com/Datatamer/tamr-client/issues/323) Documentation for setting `dtype=str` before calling `client.datasets.create_from_dataframe` From 6a47d226e10d9ef181123c07be6a29bc6bfa232f Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Wed, 25 Mar 2020 11:42:48 -0400 Subject: [PATCH 295/632] Update tests/unit/test_project.py Co-Authored-By: skalish <39866163+skalish@users.noreply.github.com> --- tests/unit/test_project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index d0d309f1..92bb5a6a 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -75,7 +75,7 @@ def test_project_by_name__raises_when_not_found(self): tamr.projects.by_name(self.project_name) @responses.activate - def test_dataset_by_name(self): + def test_project_by_name(self): responses.add(responses.GET, self.project_list_url, json=self.project_json) auth = UsernamePasswordAuth("username", "password") tamr = Client(auth) From af79dc1058809673d2b7c5d4e88c3861a7e31da6 Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Wed, 25 Mar 2020 13:32:15 -0400 Subject: [PATCH 296/632] added note to quickstart docs --- docs/user-guide/quickstart.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/user-guide/quickstart.md b/docs/user-guide/quickstart.md index 88a73abb..a77dd1ec 100644 --- a/docs/user-guide/quickstart.md +++ b/docs/user-guide/quickstart.md @@ -57,6 +57,9 @@ E.g. To fetch the project with name `'Number 1'`: ```python project = tamr.projects.by_name('Number 1') ``` +``` note:: + If working with projects over multiple instances, consider using ``by_external_id``. +``` ## Resource relationships Related resources (like a project and its unified dataset) can be accessed through specific methods. From 93f8b9ef315c6d4e8ae90d9468f672a44cfb958b Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Wed, 25 Mar 2020 13:43:02 -0400 Subject: [PATCH 297/632] Update docs/user-guide/quickstart.md Co-Authored-By: Pedro Cattori --- docs/user-guide/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-guide/quickstart.md b/docs/user-guide/quickstart.md index a77dd1ec..35b0dfd4 100644 --- a/docs/user-guide/quickstart.md +++ b/docs/user-guide/quickstart.md @@ -58,7 +58,7 @@ E.g. To fetch the project with name `'Number 1'`: project = tamr.projects.by_name('Number 1') ``` ``` note:: - If working with projects over multiple instances, consider using ``by_external_id``. + If working with projects over across Tamr instances for migrations or promotions, use external IDs ( via ``by_external_id``) instead of name (via ``by_name``). ``` ## Resource relationships From 85ac0cc3260495c7f485c867590de99e52fe1e29 Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Wed, 25 Mar 2020 13:44:38 -0400 Subject: [PATCH 298/632] Fixed a couple typos in docs. --- docs/user-guide/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-guide/quickstart.md b/docs/user-guide/quickstart.md index 35b0dfd4..22962d75 100644 --- a/docs/user-guide/quickstart.md +++ b/docs/user-guide/quickstart.md @@ -58,7 +58,7 @@ E.g. To fetch the project with name `'Number 1'`: project = tamr.projects.by_name('Number 1') ``` ``` note:: - If working with projects over across Tamr instances for migrations or promotions, use external IDs ( via ``by_external_id``) instead of name (via ``by_name``). + If working with projects across Tamr instances for migrations or promotions, use external IDs (via ``by_external_id``) instead of name (via ``by_name``). ``` ## Resource relationships From d33a7fb65aaadeba80ce1bf2c6dc7c20119e6444 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 27 Mar 2020 14:27:58 -0400 Subject: [PATCH 299/632] fix(typecheck): Non-zero exit code when typechecks fail Setting `warn=True` will cause invoke to always exit with 0 exit code, so we inspect the result of the invoke run(s), and manually set the exit code for the typecheck task accordingly. --- tasks.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index d74c221f..77470a59 100644 --- a/tasks.py +++ b/tasks.py @@ -1,4 +1,5 @@ from pathlib import Path +import sys from invoke import task @@ -24,15 +25,22 @@ def format(c, fix=False): @task def typecheck(c, warn=True): + exit_code = 0 repo = Path(".") tc = repo / "tamr_client" - c.run(f"poetry run mypy --package {tc}", echo=True, pty=True, warn=warn) + result = c.run(f"poetry run mypy --package {tc}", echo=True, pty=True, warn=warn) + if not result.exited == 0: + exit_code = result.exited tc_tests = " ".join( str(x) for x in (repo / "tests" / "tamr_client").glob("**/*.py") ) c.run(f"poetry run mypy {tc_tests}", echo=True, pty=True, warn=warn) + if not result.exited == 0: + exit_code = result.exited + + sys.exit(exit_code) @task From e12d9bae649f2393dae5bcae0b499e3add491d77 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 20 Mar 2020 21:59:55 -0400 Subject: [PATCH 300/632] Add record upsert and delete functionality. --- tamr_client/__init__.py | 3 + tamr_client/datasets/record.py | 129 +++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 tamr_client/datasets/record.py diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 2b8f9119..35bd68af 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -56,3 +56,6 @@ AttributeNotFound, ) import tamr_client.attributes.attribute as attribute + +# records +import tamr_client.datasets.record as record diff --git a/tamr_client/datasets/record.py b/tamr_client/datasets/record.py new file mode 100644 index 00000000..60ee03c7 --- /dev/null +++ b/tamr_client/datasets/record.py @@ -0,0 +1,129 @@ +""" +See https://docs.tamr.com/reference/record +""" +import json +from typing import Dict, Iterable + +import tamr_client as tc +from tamr_client.types import JsonDict + + +class PrimaryKeyNotFound(Exception): + """Raised when referencing a primary key by name that does not exist.""" + + pass + + +def _update( + session: tc.Session, dataset: tc.Dataset, updates: Iterable[Dict], **json_args +) -> JsonDict: + """Send a batch of record creations/updates/deletions to this dataset. + You probably want to use :func:`~tamr_client.dataset.upsert_records` + or :func:`~tamr_client.dataset.delete_records` instead. + + Args: + dataset: Dataset containing records to be updated + updates: Each update should be formatted as specified in the `Public Docs for Dataset updates `_. + `**json_args`: Arguments to pass to the JSON `dumps` function, as documented `here `_. + Some of these, such as `indent`, may not work with Tamr. + + Returns: + JSON response body from server + + Raises: + requests.HTTPError: If an HTTP error is encountered + """ + stringified_updates = (json.dumps(update, **json_args) for update in updates) + r = session.post( + str(dataset.url) + ":updateRecords", + headers={"Content-Encoding": "utf-8"}, + data=stringified_updates, + ) + return tc.response.successful(r).json() + + +def upsert( + session: tc.Session, + dataset: tc.Dataset, + records: Iterable[Dict], + *, + primary_key_name: str, + **json_args, +) -> JsonDict: + """Create or update the specified records. + + Args: + dataset: Dataset to receive record updates + records: The records to update, as dictionaries + primary_key_name: The primary key for these records, which must be a key in each record dictionary + `**json_args`: Arguments to pass to the JSON `dumps` function, as documented `here `_. + Some of these, such as `indent`, may not work with Tamr. + + Returns: + JSON response body from server + + Raises: + requests.HTTPError: If an HTTP error is encountered + KeyError: If primary_key_name does not match dataset primary key + KeyError: If primary_key_name not in a record dictionary + """ + if primary_key_name not in dataset.key_attribute_names: + raise PrimaryKeyNotFound( + f"Primary key: {primary_key_name} is not in dataset key attribute names: {dataset.key_attribute_names}" + ) + updates = ( + {"action": "CREATE", "recordId": record[primary_key_name], "record": record} + for record in records + ) + return _update(session, dataset, updates, **json_args) + + +def delete_by_id( + session: tc.Session, dataset: tc.Dataset, record_ids: Iterable[Dict] +) -> JsonDict: + """Deletes the specified records. + + Args: + dataset: Dataset from which to delete records + record_ids: The IDs of the records to delete_records + + Returns: + JSON response body from server + + Raises: + requests.HTTPError: If an HTTP error is encountered + """ + updates = ({"action": "DELETE", "recordId": rid} for rid in record_ids) + return _update(session, dataset, updates) + + +def delete( + session: tc.Session, + dataset: tc.Dataset, + records: Iterable[Dict], + *, + primary_key_name: str, +) -> JsonDict: + """Deletes the specified records, based on primary key values. Does not check that other record values match. + + Args: + dataset: Dateset from which to delete records + records: The records to update, as dictionaries + primary_key_name: The primary key for these records, which must be a key in each record dictionary + `**json_args`: Arguments to pass to the JSON `dumps` function, as documented `here `_. + Some of these, such as `indent`, may not work with Tamr. + + Returns: + JSON response body from server + + Raises: + requests.HTTPError: If an HTTP error is encountered + KeyError: If primary_key_name does not match dataset primary key + KeyError: If primary_key_name not in a record dictionary + """ + if primary_key_name not in dataset.key_attribute_names: + raise PrimaryKeyNotFound( + f"Primary key: {primary_key_name} is not in dataset key attribute names: {dataset.key_attribute_names}" + ) + ids = (record[primary_key_name] for record in records) + return delete_by_id(session, dataset, ids) From e7e69e9015a646188030bf34d1b83bee326dee67 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 20 Mar 2020 22:02:43 -0400 Subject: [PATCH 301/632] Add convenience function upsert_from_dataframe() for updating datasets from records stored in pandas DataFrame. --- tamr_client/datasets/dataframe.py | 46 +++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tamr_client/datasets/dataframe.py diff --git a/tamr_client/datasets/dataframe.py b/tamr_client/datasets/dataframe.py new file mode 100644 index 00000000..c75c7a52 --- /dev/null +++ b/tamr_client/datasets/dataframe.py @@ -0,0 +1,46 @@ +""" +Convenient functionality for interacting with pandas DataFrames. +""" + +import pandas as pd + +import tamr_client as tc +from tamr_client.datasets.dataset import Dataset +from tamr_client.datasets.record import PrimaryKeyNotFound, upsert +from tamr_client.types import JsonDict + + +def upsert_from_dataframe( + session: tc.Session, + dataset: Dataset, + df: pd.DataFrame, + *, + primary_key_name: str, + ignore_nan: bool = True, +) -> JsonDict: + """Upserts a record for each row of `df` with attributes for each column in `df`. + Args: + dataset: Dataset to receive record updates + df: The DataFrame containing records to be upserted + primary_key_name: The primary key of the dataset. Must be a column of `df`. + ignore_nan: Whether to convert `NaN` values to `null` before upserting records to Tamr. If `False` and `NaN` is in `df`, this function will fail. Optional, default is `True`. + + Returns: + JSON response body from the server + + Raises: + requests.HTTPError: If an HTTP error is encountered + KeyError: If `primary_key_name` is not a column in `df`. + """ + if primary_key_name not in df.columns: + raise PrimaryKeyNotFound( + f"Primary key: {primary_key_name} is not in DataFrame column names: {df.columns}" + ) + records = df.to_dict(orient="records") + return upsert( + session, + dataset, + records, + primary_key_name=primary_key_name, + ignore_nan=ignore_nan, + ) From bccb86f1dde5a35243d54b2e127501de0ddc8270 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 25 Mar 2020 19:19:02 -0400 Subject: [PATCH 302/632] Replace use of simplejson with standard library json in tamr_client. Use of ignore_nan parameter no longer supported in BETA. --- tamr_client/datasets/dataframe.py | 22 ++++++---------------- tamr_client/datasets/record.py | 29 +++++++++++------------------ 2 files changed, 17 insertions(+), 34 deletions(-) diff --git a/tamr_client/datasets/dataframe.py b/tamr_client/datasets/dataframe.py index c75c7a52..b242e96d 100644 --- a/tamr_client/datasets/dataframe.py +++ b/tamr_client/datasets/dataframe.py @@ -2,6 +2,8 @@ Convenient functionality for interacting with pandas DataFrames. """ +import json + import pandas as pd import tamr_client as tc @@ -11,36 +13,24 @@ def upsert_from_dataframe( - session: tc.Session, - dataset: Dataset, - df: pd.DataFrame, - *, - primary_key_name: str, - ignore_nan: bool = True, + session: tc.Session, dataset: Dataset, df: pd.DataFrame, *, primary_key_name: str ) -> JsonDict: """Upserts a record for each row of `df` with attributes for each column in `df`. Args: dataset: Dataset to receive record updates df: The DataFrame containing records to be upserted primary_key_name: The primary key of the dataset. Must be a column of `df`. - ignore_nan: Whether to convert `NaN` values to `null` before upserting records to Tamr. If `False` and `NaN` is in `df`, this function will fail. Optional, default is `True`. Returns: JSON response body from the server Raises: requests.HTTPError: If an HTTP error is encountered - KeyError: If `primary_key_name` is not a column in `df`. + PrimaryKeyNotFound: If `primary_key_name` is not a column in `df`. """ if primary_key_name not in df.columns: raise PrimaryKeyNotFound( f"Primary key: {primary_key_name} is not in DataFrame column names: {df.columns}" ) - records = df.to_dict(orient="records") - return upsert( - session, - dataset, - records, - primary_key_name=primary_key_name, - ignore_nan=ignore_nan, - ) + records = json.loads(df.to_json(orient="records")) + return upsert(session, dataset, records, primary_key_name=primary_key_name) diff --git a/tamr_client/datasets/record.py b/tamr_client/datasets/record.py index 60ee03c7..e34fed2f 100644 --- a/tamr_client/datasets/record.py +++ b/tamr_client/datasets/record.py @@ -2,7 +2,7 @@ See https://docs.tamr.com/reference/record """ import json -from typing import Dict, Iterable +from typing import Dict, Iterable, Union import tamr_client as tc from tamr_client.types import JsonDict @@ -15,7 +15,7 @@ class PrimaryKeyNotFound(Exception): def _update( - session: tc.Session, dataset: tc.Dataset, updates: Iterable[Dict], **json_args + session: tc.Session, dataset: tc.Dataset, updates: Iterable[Dict] ) -> JsonDict: """Send a batch of record creations/updates/deletions to this dataset. You probably want to use :func:`~tamr_client.dataset.upsert_records` @@ -24,8 +24,6 @@ def _update( Args: dataset: Dataset containing records to be updated updates: Each update should be formatted as specified in the `Public Docs for Dataset updates `_. - `**json_args`: Arguments to pass to the JSON `dumps` function, as documented `here `_. - Some of these, such as `indent`, may not work with Tamr. Returns: JSON response body from server @@ -33,7 +31,7 @@ def _update( Raises: requests.HTTPError: If an HTTP error is encountered """ - stringified_updates = (json.dumps(update, **json_args) for update in updates) + stringified_updates = (json.dumps(update) for update in updates) r = session.post( str(dataset.url) + ":updateRecords", headers={"Content-Encoding": "utf-8"}, @@ -48,7 +46,6 @@ def upsert( records: Iterable[Dict], *, primary_key_name: str, - **json_args, ) -> JsonDict: """Create or update the specified records. @@ -56,16 +53,14 @@ def upsert( dataset: Dataset to receive record updates records: The records to update, as dictionaries primary_key_name: The primary key for these records, which must be a key in each record dictionary - `**json_args`: Arguments to pass to the JSON `dumps` function, as documented `here `_. - Some of these, such as `indent`, may not work with Tamr. Returns: JSON response body from server Raises: requests.HTTPError: If an HTTP error is encountered - KeyError: If primary_key_name does not match dataset primary key - KeyError: If primary_key_name not in a record dictionary + PrimaryKeyNotFound: If primary_key_name does not match dataset primary key + PrimaryKeyNotFound: If primary_key_name not in a record dictionary """ if primary_key_name not in dataset.key_attribute_names: raise PrimaryKeyNotFound( @@ -75,11 +70,11 @@ def upsert( {"action": "CREATE", "recordId": record[primary_key_name], "record": record} for record in records ) - return _update(session, dataset, updates, **json_args) + return _update(session, dataset, updates) -def delete_by_id( - session: tc.Session, dataset: tc.Dataset, record_ids: Iterable[Dict] +def _delete_by_id( + session: tc.Session, dataset: tc.Dataset, record_ids: Iterable[Union[str, int]] ) -> JsonDict: """Deletes the specified records. @@ -110,20 +105,18 @@ def delete( dataset: Dateset from which to delete records records: The records to update, as dictionaries primary_key_name: The primary key for these records, which must be a key in each record dictionary - `**json_args`: Arguments to pass to the JSON `dumps` function, as documented `here `_. - Some of these, such as `indent`, may not work with Tamr. Returns: JSON response body from server Raises: requests.HTTPError: If an HTTP error is encountered - KeyError: If primary_key_name does not match dataset primary key - KeyError: If primary_key_name not in a record dictionary + PrimaryKeyNotFound: If primary_key_name does not match dataset primary key + PrimaryKeyNotFound: If primary_key_name not in a record dictionary """ if primary_key_name not in dataset.key_attribute_names: raise PrimaryKeyNotFound( f"Primary key: {primary_key_name} is not in dataset key attribute names: {dataset.key_attribute_names}" ) ids = (record[primary_key_name] for record in records) - return delete_by_id(session, dataset, ids) + return _delete_by_id(session, dataset, ids) From 3e7b3c938d26b7a040055cccf01efd8977f088e8 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 25 Mar 2020 19:19:53 -0400 Subject: [PATCH 303/632] Add tests of record update and delete functions in tamr_client. --- tests/tamr_client/datasets/test_record.py | 139 ++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 tests/tamr_client/datasets/test_record.py diff --git a/tests/tamr_client/datasets/test_record.py b/tests/tamr_client/datasets/test_record.py new file mode 100644 index 00000000..5009d042 --- /dev/null +++ b/tests/tamr_client/datasets/test_record.py @@ -0,0 +1,139 @@ +from functools import partial +import json +from typing import Dict + +import pytest +import responses + +import tamr_client as tc +import tests.tamr_client.utils as utils + + +@responses.activate +def test_update(): + s = utils.session() + dataset = utils.dataset() + + url = tc.URL(path="datasets/1:updateRecords") + updates = records_to_updates(_records_json) + snoop: Dict = {} + responses.add_callback( + responses.POST, url.__str__(), partial(create_callback, snoop=snoop, status=200) + ) + + response = tc.datasets.record._update(s, dataset, updates) + assert response == _response_json + assert snoop["payload"] == stringify(updates) + + +@responses.activate +def test_upsert(): + s = utils.session() + dataset = utils.dataset() + + url = tc.URL(path="datasets/1:updateRecords") + updates = records_to_updates(_records_json) + snoop: Dict = {} + responses.add_callback( + responses.POST, url.__str__(), partial(create_callback, snoop=snoop, status=200) + ) + + response = tc.datasets.record.upsert( + s, dataset, _records_json, primary_key_name="primary_key" + ) + assert response == _response_json + assert snoop["payload"] == stringify(updates) + + +@responses.activate +def test_upsert_primary_key_not_found(): + s = utils.session() + dataset = utils.dataset() + + with pytest.raises(tc.datasets.record.PrimaryKeyNotFound): + tc.datasets.record.upsert( + s, dataset, _records_json, primary_key_name="wrong_primary_key" + ) + + +@responses.activate +def test_delete_by_ids(): + s = utils.session() + dataset = utils.dataset() + + url = tc.URL(path="datasets/1:updateRecords") + deletes = records_to_deletes(_records_json) + snoop: Dict = {} + responses.add_callback( + responses.POST, url.__str__(), partial(create_callback, snoop=snoop, status=200) + ) + + ids = (r["primary_key"] for r in _records_json) + + response = tc.datasets.record._delete_by_id(s, dataset, ids) + assert response == _response_json + assert snoop["payload"] == stringify(deletes) + + +@responses.activate +def test_delete(): + s = utils.session() + dataset = utils.dataset() + + url = tc.URL(path="datasets/1:updateRecords") + deletes = records_to_deletes(_records_json) + snoop: Dict = {} + responses.add_callback( + responses.POST, url.__str__(), partial(create_callback, snoop=snoop, status=200) + ) + + response = tc.datasets.record.delete( + s, dataset, _records_json, primary_key_name="primary_key" + ) + assert response == _response_json + assert snoop["payload"] == stringify(deletes) + + +@responses.activate +def test_delete_primary_key_not_found(): + s = utils.session() + dataset = utils.dataset() + + with pytest.raises(tc.datasets.record.PrimaryKeyNotFound): + tc.datasets.record.delete( + s, dataset, _records_json, primary_key_name="wrong_primary_key" + ) + + +def create_callback(request, snoop, status): + snoop["payload"] = list(request.body) + return status, {}, json.dumps(_response_json) + + +def records_to_deletes(records): + return [ + {"action": "DELETE", "recordId": i} for i, record in enumerate(records, start=1) + ] + + +def records_to_updates(records): + return [ + {"action": "CREATE", "recordId": i, "record": record} + for i, record in enumerate(records, start=1) + ] + + +def stringify(updates): + return [json.dumps(u) for u in updates] + + +_dataset_id = "1" +_dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/{_dataset_id}" + +_records_json = [{"primary_key": 1}, {"primary_key": 2}] +_nan_records_json = [{"primary_key": float("nan")}, {"primary_key": float("nan")}] +_response_json = { + "numCommandsProcessed": 2, + "allCommandsSucceeded": True, + "validationErrors": [], +} From cccc6a87866a2334134860df418a062e1ca8c395 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 25 Mar 2020 19:27:26 -0400 Subject: [PATCH 304/632] Rename function upsert_from_dataframe() to upsert(). Example call is tc.datasets.dataframe.upsert() so avoid repetition. --- tamr_client/datasets/dataframe.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tamr_client/datasets/dataframe.py b/tamr_client/datasets/dataframe.py index b242e96d..b00fe811 100644 --- a/tamr_client/datasets/dataframe.py +++ b/tamr_client/datasets/dataframe.py @@ -8,11 +8,11 @@ import tamr_client as tc from tamr_client.datasets.dataset import Dataset -from tamr_client.datasets.record import PrimaryKeyNotFound, upsert +from tamr_client.datasets.record import PrimaryKeyNotFound from tamr_client.types import JsonDict -def upsert_from_dataframe( +def upsert( session: tc.Session, dataset: Dataset, df: pd.DataFrame, *, primary_key_name: str ) -> JsonDict: """Upserts a record for each row of `df` with attributes for each column in `df`. @@ -33,4 +33,6 @@ def upsert_from_dataframe( f"Primary key: {primary_key_name} is not in DataFrame column names: {df.columns}" ) records = json.loads(df.to_json(orient="records")) - return upsert(session, dataset, records, primary_key_name=primary_key_name) + return tc.datasets.record.upsert( + session, dataset, records, primary_key_name=primary_key_name + ) From f8ae827c7aeeaed0284483721236a026fe9182dc Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 25 Mar 2020 19:30:50 -0400 Subject: [PATCH 305/632] Add datasets.dataframe to imports in tamr_client/__init__.py --- tamr_client/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 35bd68af..509f77fe 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -59,3 +59,6 @@ # records import tamr_client.datasets.record as record + +# dataframe +import tamr_client.datasets.dataframe as dataframe From 11ae08db05ac518d1cdd613ce8d46bc5b4de56df Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 25 Mar 2020 19:37:39 -0400 Subject: [PATCH 306/632] Add tests of dataframe upsert function in tamr_client. --- tests/tamr_client/datasets/test_dataframe.py | 78 ++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/tamr_client/datasets/test_dataframe.py diff --git a/tests/tamr_client/datasets/test_dataframe.py b/tests/tamr_client/datasets/test_dataframe.py new file mode 100644 index 00000000..3358a3fa --- /dev/null +++ b/tests/tamr_client/datasets/test_dataframe.py @@ -0,0 +1,78 @@ +from functools import partial +import json +from typing import Dict + +import pandas as pd +import pytest +import responses + +import tamr_client as tc +import tests.tamr_client.utils as utils + + +@responses.activate +def test_upsert(): + s = utils.session() + dataset = utils.dataset() + + url = tc.URL(path="datasets/1:updateRecords") + updates = records_to_updates(_records_json) + snoop: Dict = {} + responses.add_callback( + responses.POST, url.__str__(), partial(create_callback, snoop=snoop, status=200) + ) + + df = pd.DataFrame(_records_json) + + response = tc.datasets.dataframe.upsert( + s, dataset, df, primary_key_name="primary_key" + ) + assert response == _response_json + assert snoop["payload"] == stringify(updates) + + +@responses.activate +def test_upsert_primary_key_not_found(): + s = utils.session() + dataset = utils.dataset() + + df = pd.DataFrame(_records_json) + + with pytest.raises(tc.datasets.record.PrimaryKeyNotFound): + tc.datasets.dataframe.upsert( + s, dataset, df, primary_key_name="wrong_primary_key" + ) + + +def create_callback(request, snoop, status): + snoop["payload"] = list(request.body) + return status, {}, json.dumps(_response_json) + + +def records_to_deletes(records): + return [ + {"action": "DELETE", "recordId": i} for i, record in enumerate(records, start=1) + ] + + +def records_to_updates(records): + return [ + {"action": "CREATE", "recordId": i, "record": record} + for i, record in enumerate(records, start=1) + ] + + +def stringify(updates): + return [json.dumps(u) for u in updates] + + +_dataset_id = "1" +_dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/{_dataset_id}" + +_records_json = [{"primary_key": 1}, {"primary_key": 2}] +_nan_records_json = [{"primary_key": float("nan")}, {"primary_key": float("nan")}] +_response_json = { + "numCommandsProcessed": 2, + "allCommandsSucceeded": True, + "validationErrors": [], +} From 2e8d0dbbd96323df651c100bc53f73c48e9edd62 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 25 Mar 2020 20:05:06 -0400 Subject: [PATCH 307/632] Simplify calls of functions in new testing. --- tests/tamr_client/datasets/test_dataframe.py | 10 +++------- tests/tamr_client/datasets/test_record.py | 16 ++++++++-------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/tests/tamr_client/datasets/test_dataframe.py b/tests/tamr_client/datasets/test_dataframe.py index 3358a3fa..52b186b1 100644 --- a/tests/tamr_client/datasets/test_dataframe.py +++ b/tests/tamr_client/datasets/test_dataframe.py @@ -24,9 +24,7 @@ def test_upsert(): df = pd.DataFrame(_records_json) - response = tc.datasets.dataframe.upsert( - s, dataset, df, primary_key_name="primary_key" - ) + response = tc.dataframe.upsert(s, dataset, df, primary_key_name="primary_key") assert response == _response_json assert snoop["payload"] == stringify(updates) @@ -38,10 +36,8 @@ def test_upsert_primary_key_not_found(): df = pd.DataFrame(_records_json) - with pytest.raises(tc.datasets.record.PrimaryKeyNotFound): - tc.datasets.dataframe.upsert( - s, dataset, df, primary_key_name="wrong_primary_key" - ) + with pytest.raises(tc.record.PrimaryKeyNotFound): + tc.dataframe.upsert(s, dataset, df, primary_key_name="wrong_primary_key") def create_callback(request, snoop, status): diff --git a/tests/tamr_client/datasets/test_record.py b/tests/tamr_client/datasets/test_record.py index 5009d042..b1f80422 100644 --- a/tests/tamr_client/datasets/test_record.py +++ b/tests/tamr_client/datasets/test_record.py @@ -21,7 +21,7 @@ def test_update(): responses.POST, url.__str__(), partial(create_callback, snoop=snoop, status=200) ) - response = tc.datasets.record._update(s, dataset, updates) + response = tc.record._update(s, dataset, updates) assert response == _response_json assert snoop["payload"] == stringify(updates) @@ -38,7 +38,7 @@ def test_upsert(): responses.POST, url.__str__(), partial(create_callback, snoop=snoop, status=200) ) - response = tc.datasets.record.upsert( + response = tc.record.upsert( s, dataset, _records_json, primary_key_name="primary_key" ) assert response == _response_json @@ -50,8 +50,8 @@ def test_upsert_primary_key_not_found(): s = utils.session() dataset = utils.dataset() - with pytest.raises(tc.datasets.record.PrimaryKeyNotFound): - tc.datasets.record.upsert( + with pytest.raises(tc.record.PrimaryKeyNotFound): + tc.record.upsert( s, dataset, _records_json, primary_key_name="wrong_primary_key" ) @@ -70,7 +70,7 @@ def test_delete_by_ids(): ids = (r["primary_key"] for r in _records_json) - response = tc.datasets.record._delete_by_id(s, dataset, ids) + response = tc.record._delete_by_id(s, dataset, ids) assert response == _response_json assert snoop["payload"] == stringify(deletes) @@ -87,7 +87,7 @@ def test_delete(): responses.POST, url.__str__(), partial(create_callback, snoop=snoop, status=200) ) - response = tc.datasets.record.delete( + response = tc.record.delete( s, dataset, _records_json, primary_key_name="primary_key" ) assert response == _response_json @@ -99,8 +99,8 @@ def test_delete_primary_key_not_found(): s = utils.session() dataset = utils.dataset() - with pytest.raises(tc.datasets.record.PrimaryKeyNotFound): - tc.datasets.record.delete( + with pytest.raises(tc.record.PrimaryKeyNotFound): + tc.record.delete( s, dataset, _records_json, primary_key_name="wrong_primary_key" ) From bc9eb9b76e1e93f740b600495b9d153bb78759bd Mon Sep 17 00:00:00 2001 From: skalish <39866163+skalish@users.noreply.github.com> Date: Thu, 26 Mar 2020 10:02:55 -0400 Subject: [PATCH 308/632] Update tests/tamr_client/datasets/test_record.py Co-Authored-By: Pedro Cattori --- tests/tamr_client/datasets/test_record.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tamr_client/datasets/test_record.py b/tests/tamr_client/datasets/test_record.py index b1f80422..f788615e 100644 --- a/tests/tamr_client/datasets/test_record.py +++ b/tests/tamr_client/datasets/test_record.py @@ -18,7 +18,7 @@ def test_update(): updates = records_to_updates(_records_json) snoop: Dict = {} responses.add_callback( - responses.POST, url.__str__(), partial(create_callback, snoop=snoop, status=200) + responses.POST, str(url), partial(create_callback, snoop=snoop, status=200) ) response = tc.record._update(s, dataset, updates) From 42af6f5da0ea9fc302149781860ad24a8ffd2789 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 26 Mar 2020 10:15:18 -0400 Subject: [PATCH 309/632] Add description to datasets.record.py module docstring. Replace import of module-defined types and errors to tc.Type and tc.Error. Rename test helper function create_callback() to more descriptive capture_payload(). Prefix test helper functions with _ and remove unused variables. --- tamr_client/__init__.py | 1 + tamr_client/datasets/dataframe.py | 6 ++-- tamr_client/datasets/record.py | 2 ++ tests/tamr_client/datasets/test_record.py | 37 +++++++++++------------ 4 files changed, 22 insertions(+), 24 deletions(-) diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 509f77fe..19c273a5 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -58,6 +58,7 @@ import tamr_client.attributes.attribute as attribute # records +from tamr_client.datasets.record import PrimaryKeyNotFound import tamr_client.datasets.record as record # dataframe diff --git a/tamr_client/datasets/dataframe.py b/tamr_client/datasets/dataframe.py index b00fe811..f25abdb5 100644 --- a/tamr_client/datasets/dataframe.py +++ b/tamr_client/datasets/dataframe.py @@ -7,13 +7,11 @@ import pandas as pd import tamr_client as tc -from tamr_client.datasets.dataset import Dataset -from tamr_client.datasets.record import PrimaryKeyNotFound from tamr_client.types import JsonDict def upsert( - session: tc.Session, dataset: Dataset, df: pd.DataFrame, *, primary_key_name: str + session: tc.Session, dataset: tc.Dataset, df: pd.DataFrame, *, primary_key_name: str ) -> JsonDict: """Upserts a record for each row of `df` with attributes for each column in `df`. Args: @@ -29,7 +27,7 @@ def upsert( PrimaryKeyNotFound: If `primary_key_name` is not a column in `df`. """ if primary_key_name not in df.columns: - raise PrimaryKeyNotFound( + raise tc.PrimaryKeyNotFound( f"Primary key: {primary_key_name} is not in DataFrame column names: {df.columns}" ) records = json.loads(df.to_json(orient="records")) diff --git a/tamr_client/datasets/record.py b/tamr_client/datasets/record.py index e34fed2f..6418fc88 100644 --- a/tamr_client/datasets/record.py +++ b/tamr_client/datasets/record.py @@ -1,5 +1,7 @@ """ See https://docs.tamr.com/reference/record +"The recommended approach for interacting with records is to use the upsert and delete functions for all use cases they +can handle. For more advanced use cases, the underlying _update function can be used directly." """ import json from typing import Dict, Iterable, Union diff --git a/tests/tamr_client/datasets/test_record.py b/tests/tamr_client/datasets/test_record.py index f788615e..5249cfb9 100644 --- a/tests/tamr_client/datasets/test_record.py +++ b/tests/tamr_client/datasets/test_record.py @@ -15,15 +15,15 @@ def test_update(): dataset = utils.dataset() url = tc.URL(path="datasets/1:updateRecords") - updates = records_to_updates(_records_json) + updates = _records_to_updates(_records_json) snoop: Dict = {} responses.add_callback( - responses.POST, str(url), partial(create_callback, snoop=snoop, status=200) + responses.POST, str(url), partial(capture_payload, snoop=snoop, status=200) ) response = tc.record._update(s, dataset, updates) assert response == _response_json - assert snoop["payload"] == stringify(updates) + assert snoop["payload"] == _stringify(updates) @responses.activate @@ -32,17 +32,17 @@ def test_upsert(): dataset = utils.dataset() url = tc.URL(path="datasets/1:updateRecords") - updates = records_to_updates(_records_json) + updates = _records_to_updates(_records_json) snoop: Dict = {} responses.add_callback( - responses.POST, url.__str__(), partial(create_callback, snoop=snoop, status=200) + responses.POST, str(url), partial(capture_payload, snoop=snoop, status=200) ) response = tc.record.upsert( s, dataset, _records_json, primary_key_name="primary_key" ) assert response == _response_json - assert snoop["payload"] == stringify(updates) + assert snoop["payload"] == _stringify(updates) @responses.activate @@ -62,17 +62,17 @@ def test_delete_by_ids(): dataset = utils.dataset() url = tc.URL(path="datasets/1:updateRecords") - deletes = records_to_deletes(_records_json) + deletes = _records_to_deletes(_records_json) snoop: Dict = {} responses.add_callback( - responses.POST, url.__str__(), partial(create_callback, snoop=snoop, status=200) + responses.POST, str(url), partial(capture_payload, snoop=snoop, status=200) ) ids = (r["primary_key"] for r in _records_json) response = tc.record._delete_by_id(s, dataset, ids) assert response == _response_json - assert snoop["payload"] == stringify(deletes) + assert snoop["payload"] == _stringify(deletes) @responses.activate @@ -81,17 +81,17 @@ def test_delete(): dataset = utils.dataset() url = tc.URL(path="datasets/1:updateRecords") - deletes = records_to_deletes(_records_json) + deletes = _records_to_deletes(_records_json) snoop: Dict = {} responses.add_callback( - responses.POST, url.__str__(), partial(create_callback, snoop=snoop, status=200) + responses.POST, str(url), partial(capture_payload, snoop=snoop, status=200) ) response = tc.record.delete( s, dataset, _records_json, primary_key_name="primary_key" ) assert response == _response_json - assert snoop["payload"] == stringify(deletes) + assert snoop["payload"] == _stringify(deletes) @responses.activate @@ -105,33 +105,30 @@ def test_delete_primary_key_not_found(): ) -def create_callback(request, snoop, status): +def capture_payload(request, snoop, status): snoop["payload"] = list(request.body) return status, {}, json.dumps(_response_json) -def records_to_deletes(records): +def _records_to_deletes(records): return [ {"action": "DELETE", "recordId": i} for i, record in enumerate(records, start=1) ] -def records_to_updates(records): +def _records_to_updates(records): return [ {"action": "CREATE", "recordId": i, "record": record} for i, record in enumerate(records, start=1) ] -def stringify(updates): +def _stringify(updates): return [json.dumps(u) for u in updates] -_dataset_id = "1" -_dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/{_dataset_id}" - _records_json = [{"primary_key": 1}, {"primary_key": 2}] -_nan_records_json = [{"primary_key": float("nan")}, {"primary_key": float("nan")}] + _response_json = { "numCommandsProcessed": 2, "allCommandsSucceeded": True, From 402785af7298f032730bcdc2ef5c31e2d2dddf1e Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 26 Mar 2020 11:03:44 -0400 Subject: [PATCH 310/632] Fix test variables and functions in test_dataframe and add docstring to _capture_payload. --- tests/tamr_client/datasets/test_dataframe.py | 27 ++++++++------------ tests/tamr_client/datasets/test_record.py | 14 ++++++---- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/tests/tamr_client/datasets/test_dataframe.py b/tests/tamr_client/datasets/test_dataframe.py index 52b186b1..a82b6766 100644 --- a/tests/tamr_client/datasets/test_dataframe.py +++ b/tests/tamr_client/datasets/test_dataframe.py @@ -16,17 +16,17 @@ def test_upsert(): dataset = utils.dataset() url = tc.URL(path="datasets/1:updateRecords") - updates = records_to_updates(_records_json) + updates = _records_to_updates(_records_json) snoop: Dict = {} responses.add_callback( - responses.POST, url.__str__(), partial(create_callback, snoop=snoop, status=200) + responses.POST, str(url), partial(_capture_payload, snoop=snoop, status=200) ) df = pd.DataFrame(_records_json) response = tc.dataframe.upsert(s, dataset, df, primary_key_name="primary_key") assert response == _response_json - assert snoop["payload"] == stringify(updates) + assert snoop["payload"] == _stringify(updates) @responses.activate @@ -40,33 +40,28 @@ def test_upsert_primary_key_not_found(): tc.dataframe.upsert(s, dataset, df, primary_key_name="wrong_primary_key") -def create_callback(request, snoop, status): +def _capture_payload(request, snoop, status): + """Capture request body within `snoop` so we can inspect that the request body is constructed correctly (e.g. for streaming requests). + + See https://github.com/getsentry/responses#dynamic-responses + """ snoop["payload"] = list(request.body) return status, {}, json.dumps(_response_json) -def records_to_deletes(records): - return [ - {"action": "DELETE", "recordId": i} for i, record in enumerate(records, start=1) - ] - - -def records_to_updates(records): +def _records_to_updates(records): return [ {"action": "CREATE", "recordId": i, "record": record} for i, record in enumerate(records, start=1) ] -def stringify(updates): +def _stringify(updates): return [json.dumps(u) for u in updates] -_dataset_id = "1" -_dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/{_dataset_id}" - _records_json = [{"primary_key": 1}, {"primary_key": 2}] -_nan_records_json = [{"primary_key": float("nan")}, {"primary_key": float("nan")}] + _response_json = { "numCommandsProcessed": 2, "allCommandsSucceeded": True, diff --git a/tests/tamr_client/datasets/test_record.py b/tests/tamr_client/datasets/test_record.py index 5249cfb9..861d5fcc 100644 --- a/tests/tamr_client/datasets/test_record.py +++ b/tests/tamr_client/datasets/test_record.py @@ -18,7 +18,7 @@ def test_update(): updates = _records_to_updates(_records_json) snoop: Dict = {} responses.add_callback( - responses.POST, str(url), partial(capture_payload, snoop=snoop, status=200) + responses.POST, str(url), partial(_capture_payload, snoop=snoop, status=200) ) response = tc.record._update(s, dataset, updates) @@ -35,7 +35,7 @@ def test_upsert(): updates = _records_to_updates(_records_json) snoop: Dict = {} responses.add_callback( - responses.POST, str(url), partial(capture_payload, snoop=snoop, status=200) + responses.POST, str(url), partial(_capture_payload, snoop=snoop, status=200) ) response = tc.record.upsert( @@ -65,7 +65,7 @@ def test_delete_by_ids(): deletes = _records_to_deletes(_records_json) snoop: Dict = {} responses.add_callback( - responses.POST, str(url), partial(capture_payload, snoop=snoop, status=200) + responses.POST, str(url), partial(_capture_payload, snoop=snoop, status=200) ) ids = (r["primary_key"] for r in _records_json) @@ -84,7 +84,7 @@ def test_delete(): deletes = _records_to_deletes(_records_json) snoop: Dict = {} responses.add_callback( - responses.POST, str(url), partial(capture_payload, snoop=snoop, status=200) + responses.POST, str(url), partial(_capture_payload, snoop=snoop, status=200) ) response = tc.record.delete( @@ -105,7 +105,11 @@ def test_delete_primary_key_not_found(): ) -def capture_payload(request, snoop, status): +def _capture_payload(request, snoop, status): + """Capture request body within `snoop` so we can inspect that the request body is constructed correctly (e.g. for streaming requests). + + See https://github.com/getsentry/responses#dynamic-responses + """ snoop["payload"] = list(request.body) return status, {}, json.dumps(_response_json) From 88a8dea42341afae5f89d51be59a0db9d98e3bf4 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 26 Mar 2020 11:27:37 -0400 Subject: [PATCH 311/632] Move shared helper functions to utils.py. --- tests/tamr_client/datasets/test_dataframe.py | 31 ++------- tests/tamr_client/datasets/test_record.py | 67 ++++++++------------ tests/tamr_client/utils.py | 26 ++++++++ 3 files changed, 61 insertions(+), 63 deletions(-) diff --git a/tests/tamr_client/datasets/test_dataframe.py b/tests/tamr_client/datasets/test_dataframe.py index a82b6766..5f9f7442 100644 --- a/tests/tamr_client/datasets/test_dataframe.py +++ b/tests/tamr_client/datasets/test_dataframe.py @@ -1,5 +1,4 @@ from functools import partial -import json from typing import Dict import pandas as pd @@ -16,17 +15,21 @@ def test_upsert(): dataset = utils.dataset() url = tc.URL(path="datasets/1:updateRecords") - updates = _records_to_updates(_records_json) + updates = utils.records_to_updates(_records_json) snoop: Dict = {} responses.add_callback( - responses.POST, str(url), partial(_capture_payload, snoop=snoop, status=200) + responses.POST, + str(url), + partial( + utils.capture_payload, snoop=snoop, status=200, response_json=_response_json + ), ) df = pd.DataFrame(_records_json) response = tc.dataframe.upsert(s, dataset, df, primary_key_name="primary_key") assert response == _response_json - assert snoop["payload"] == _stringify(updates) + assert snoop["payload"] == utils.stringify(updates) @responses.activate @@ -40,26 +43,6 @@ def test_upsert_primary_key_not_found(): tc.dataframe.upsert(s, dataset, df, primary_key_name="wrong_primary_key") -def _capture_payload(request, snoop, status): - """Capture request body within `snoop` so we can inspect that the request body is constructed correctly (e.g. for streaming requests). - - See https://github.com/getsentry/responses#dynamic-responses - """ - snoop["payload"] = list(request.body) - return status, {}, json.dumps(_response_json) - - -def _records_to_updates(records): - return [ - {"action": "CREATE", "recordId": i, "record": record} - for i, record in enumerate(records, start=1) - ] - - -def _stringify(updates): - return [json.dumps(u) for u in updates] - - _records_json = [{"primary_key": 1}, {"primary_key": 2}] _response_json = { diff --git a/tests/tamr_client/datasets/test_record.py b/tests/tamr_client/datasets/test_record.py index 861d5fcc..7ed0e4d5 100644 --- a/tests/tamr_client/datasets/test_record.py +++ b/tests/tamr_client/datasets/test_record.py @@ -1,5 +1,4 @@ from functools import partial -import json from typing import Dict import pytest @@ -15,15 +14,19 @@ def test_update(): dataset = utils.dataset() url = tc.URL(path="datasets/1:updateRecords") - updates = _records_to_updates(_records_json) + updates = utils.records_to_updates(_records_json) snoop: Dict = {} responses.add_callback( - responses.POST, str(url), partial(_capture_payload, snoop=snoop, status=200) + responses.POST, + str(url), + partial( + utils.capture_payload, snoop=snoop, status=200, response_json=_response_json + ), ) response = tc.record._update(s, dataset, updates) assert response == _response_json - assert snoop["payload"] == _stringify(updates) + assert snoop["payload"] == utils.stringify(updates) @responses.activate @@ -32,17 +35,21 @@ def test_upsert(): dataset = utils.dataset() url = tc.URL(path="datasets/1:updateRecords") - updates = _records_to_updates(_records_json) + updates = utils.records_to_updates(_records_json) snoop: Dict = {} responses.add_callback( - responses.POST, str(url), partial(_capture_payload, snoop=snoop, status=200) + responses.POST, + str(url), + partial( + utils.capture_payload, snoop=snoop, status=200, response_json=_response_json + ), ) response = tc.record.upsert( s, dataset, _records_json, primary_key_name="primary_key" ) assert response == _response_json - assert snoop["payload"] == _stringify(updates) + assert snoop["payload"] == utils.stringify(updates) @responses.activate @@ -62,17 +69,21 @@ def test_delete_by_ids(): dataset = utils.dataset() url = tc.URL(path="datasets/1:updateRecords") - deletes = _records_to_deletes(_records_json) + deletes = utils.records_to_deletes(_records_json) snoop: Dict = {} responses.add_callback( - responses.POST, str(url), partial(_capture_payload, snoop=snoop, status=200) + responses.POST, + str(url), + partial( + utils.capture_payload, snoop=snoop, status=200, response_json=_response_json + ), ) ids = (r["primary_key"] for r in _records_json) response = tc.record._delete_by_id(s, dataset, ids) assert response == _response_json - assert snoop["payload"] == _stringify(deletes) + assert snoop["payload"] == utils.stringify(deletes) @responses.activate @@ -81,17 +92,21 @@ def test_delete(): dataset = utils.dataset() url = tc.URL(path="datasets/1:updateRecords") - deletes = _records_to_deletes(_records_json) + deletes = utils.records_to_deletes(_records_json) snoop: Dict = {} responses.add_callback( - responses.POST, str(url), partial(_capture_payload, snoop=snoop, status=200) + responses.POST, + str(url), + partial( + utils.capture_payload, snoop=snoop, status=200, response_json=_response_json + ), ) response = tc.record.delete( s, dataset, _records_json, primary_key_name="primary_key" ) assert response == _response_json - assert snoop["payload"] == _stringify(deletes) + assert snoop["payload"] == utils.stringify(deletes) @responses.activate @@ -105,32 +120,6 @@ def test_delete_primary_key_not_found(): ) -def _capture_payload(request, snoop, status): - """Capture request body within `snoop` so we can inspect that the request body is constructed correctly (e.g. for streaming requests). - - See https://github.com/getsentry/responses#dynamic-responses - """ - snoop["payload"] = list(request.body) - return status, {}, json.dumps(_response_json) - - -def _records_to_deletes(records): - return [ - {"action": "DELETE", "recordId": i} for i, record in enumerate(records, start=1) - ] - - -def _records_to_updates(records): - return [ - {"action": "CREATE", "recordId": i, "record": record} - for i, record in enumerate(records, start=1) - ] - - -def _stringify(updates): - return [json.dumps(u) for u in updates] - - _records_json = [{"primary_key": 1}, {"primary_key": 2}] _response_json = { diff --git a/tests/tamr_client/utils.py b/tests/tamr_client/utils.py index 0183a911..4ce085bf 100644 --- a/tests/tamr_client/utils.py +++ b/tests/tamr_client/utils.py @@ -26,3 +26,29 @@ def dataset(): url = tc.URL(path="datasets/1") dataset = tc.Dataset(url, name="dataset.csv", key_attribute_names=("primary_key",)) return dataset + + +def capture_payload(request, snoop, status, response_json): + """Capture request body within `snoop` so we can inspect that the request body is constructed correctly (e.g. for streaming requests). + + See https://github.com/getsentry/responses#dynamic-responses + """ + snoop["payload"] = list(request.body) + return status, {}, json.dumps(response_json) + + +def records_to_deletes(records): + return [ + {"action": "DELETE", "recordId": i} for i, record in enumerate(records, start=1) + ] + + +def records_to_updates(records): + return [ + {"action": "CREATE", "recordId": i, "record": record} + for i, record in enumerate(records, start=1) + ] + + +def stringify(updates): + return [json.dumps(u) for u in updates] From a8c388a89bdb9fbf2d7fd6e2ade7079ce6df85ab Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 26 Mar 2020 11:49:44 -0400 Subject: [PATCH 312/632] Change method of converting DataFrame rows to dicts. --- tamr_client/datasets/dataframe.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tamr_client/datasets/dataframe.py b/tamr_client/datasets/dataframe.py index f25abdb5..11c08fd9 100644 --- a/tamr_client/datasets/dataframe.py +++ b/tamr_client/datasets/dataframe.py @@ -30,7 +30,9 @@ def upsert( raise tc.PrimaryKeyNotFound( f"Primary key: {primary_key_name} is not in DataFrame column names: {df.columns}" ) - records = json.loads(df.to_json(orient="records")) + # serialize records via to_json to handle `np.nan` values + serialized_records = (x[1].to_json() for x in df.iterrows()) + records = (json.loads(x) for x in serialized_records) return tc.datasets.record.upsert( session, dataset, records, primary_key_name=primary_key_name ) From 6f0833889009b98ff8b43ee717670c0139f1ed0f Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 26 Mar 2020 16:20:17 -0400 Subject: [PATCH 313/632] Update docs and fix docstrings. --- docs/beta.md | 2 +- docs/beta/datasets.md | 2 ++ docs/beta/datasets/dataframe.rst | 4 ++++ docs/beta/datasets/record.rst | 15 +++++++++++++++ tamr_client/datasets/record.py | 9 +++++---- 5 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 docs/beta/datasets/dataframe.rst create mode 100644 docs/beta/datasets/record.rst diff --git a/docs/beta.md b/docs/beta.md index b7d7d86f..59a31b02 100644 --- a/docs/beta.md +++ b/docs/beta.md @@ -10,4 +10,4 @@ * [Dataset](beta/datasets) * [Instance](beta/instance) * [Response](beta/response) - * [Session](beta/session) + * [Session](beta/session) \ No newline at end of file diff --git a/docs/beta/datasets.md b/docs/beta/datasets.md index 2b80afa8..b1fe4338 100644 --- a/docs/beta/datasets.md +++ b/docs/beta/datasets.md @@ -1,3 +1,5 @@ # Datasets * [Dataset](/beta/datasets/dataset) + * [Record](/beta/datasets/record) + * [Dataframe](/beta/datasets/dataframe) \ No newline at end of file diff --git a/docs/beta/datasets/dataframe.rst b/docs/beta/datasets/dataframe.rst new file mode 100644 index 00000000..ec02b9e2 --- /dev/null +++ b/docs/beta/datasets/dataframe.rst @@ -0,0 +1,4 @@ +Dataframe +========= + +.. autofunction:: tamr_client.dataframe.upsert diff --git a/docs/beta/datasets/record.rst b/docs/beta/datasets/record.rst new file mode 100644 index 00000000..a34941a1 --- /dev/null +++ b/docs/beta/datasets/record.rst @@ -0,0 +1,15 @@ +Record +========= + +.. automodule:: tamr_client.record + :no-members: + +.. autofunction:: tamr_client.record.upsert +.. autofunction:: tamr_client.record.delete +.. autofunction:: tamr_client.record._update + +Exceptions +---------- + +.. autoclass:: tamr_client.PrimaryKeyNotFound + :no-inherited-members: diff --git a/tamr_client/datasets/record.py b/tamr_client/datasets/record.py index 6418fc88..73b51200 100644 --- a/tamr_client/datasets/record.py +++ b/tamr_client/datasets/record.py @@ -1,7 +1,8 @@ """ See https://docs.tamr.com/reference/record -"The recommended approach for interacting with records is to use the upsert and delete functions for all use cases they -can handle. For more advanced use cases, the underlying _update function can be used directly." +"The recommended approach for interacting with records is to use the :func:`~tamr_client.record.upsert` and +:func:`~tamr_client.record.delete` functions for all use cases they can handle. For more advanced use cases, the +underlying _update function can be used directly." """ import json from typing import Dict, Iterable, Union @@ -20,8 +21,8 @@ def _update( session: tc.Session, dataset: tc.Dataset, updates: Iterable[Dict] ) -> JsonDict: """Send a batch of record creations/updates/deletions to this dataset. - You probably want to use :func:`~tamr_client.dataset.upsert_records` - or :func:`~tamr_client.dataset.delete_records` instead. + You probably want to use :func:`~tamr_client.record.upsert` + or :func:`~tamr_client.record.delete` instead. Args: dataset: Dataset containing records to be updated From befde9765590aa5285063cc154e3953c6b31af67 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 27 Mar 2020 10:52:35 -0400 Subject: [PATCH 314/632] Move logic for CREATE/DELETE record commands into their own functions in tc.records. Remove deprecated tc.record._delete_by_ids() function. --- tamr_client/datasets/record.py | 62 ++++++++++++-------- tests/tamr_client/datasets/test_dataframe.py | 5 +- tests/tamr_client/datasets/test_record.py | 38 ++++-------- tests/tamr_client/utils.py | 13 ---- 4 files changed, 52 insertions(+), 66 deletions(-) diff --git a/tamr_client/datasets/record.py b/tamr_client/datasets/record.py index 73b51200..2e5ae07a 100644 --- a/tamr_client/datasets/record.py +++ b/tamr_client/datasets/record.py @@ -5,7 +5,7 @@ underlying _update function can be used directly." """ import json -from typing import Dict, Iterable, Union +from typing import Dict, Iterable import tamr_client as tc from tamr_client.types import JsonDict @@ -70,31 +70,11 @@ def upsert( f"Primary key: {primary_key_name} is not in dataset key attribute names: {dataset.key_attribute_names}" ) updates = ( - {"action": "CREATE", "recordId": record[primary_key_name], "record": record} - for record in records + _create_command(record, primary_key_name=primary_key_name) for record in records ) return _update(session, dataset, updates) -def _delete_by_id( - session: tc.Session, dataset: tc.Dataset, record_ids: Iterable[Union[str, int]] -) -> JsonDict: - """Deletes the specified records. - - Args: - dataset: Dataset from which to delete records - record_ids: The IDs of the records to delete_records - - Returns: - JSON response body from server - - Raises: - requests.HTTPError: If an HTTP error is encountered - """ - updates = ({"action": "DELETE", "recordId": rid} for rid in record_ids) - return _update(session, dataset, updates) - - def delete( session: tc.Session, dataset: tc.Dataset, @@ -102,10 +82,10 @@ def delete( *, primary_key_name: str, ) -> JsonDict: - """Deletes the specified records, based on primary key values. Does not check that other record values match. + """Deletes the specified records, based on primary key values. Does not check that other attribute values match. Args: - dataset: Dateset from which to delete records + dataset: Dataset from which to delete records records: The records to update, as dictionaries primary_key_name: The primary key for these records, which must be a key in each record dictionary @@ -121,5 +101,35 @@ def delete( raise PrimaryKeyNotFound( f"Primary key: {primary_key_name} is not in dataset key attribute names: {dataset.key_attribute_names}" ) - ids = (record[primary_key_name] for record in records) - return _delete_by_id(session, dataset, ids) + updates = ( + _delete_command(record, primary_key_name=primary_key_name) for record in records + ) + return _update(session, dataset, updates) + + +def _create_command(record: Dict, *, primary_key_name: str) -> Dict: + """Generates the CREATE command formatted as specified in the `Public Docs for Dataset updates + `_. + + Args: + record: The record to create, as a dictionary + primary_key_name: The primary key for this record, which must be a key in the dictionary + + Returns: + The CREATE command in the proper format + """ + return {"action": "CREATE", "recordId": record[primary_key_name], "record": record} + + +def _delete_command(record: Dict, *, primary_key_name: str) -> Dict: + """Generates the DELETE command formatted as specified in the `Public Docs for Dataset updates + `_. + + Args: + record: The record to delete, as a dictionary + primary_key_name: The primary key for this record, which must be a key in the dictionary + + Returns: + The DELETE command in the proper format + """ + return {"action": "DELETE", "recordId": record[primary_key_name]} diff --git a/tests/tamr_client/datasets/test_dataframe.py b/tests/tamr_client/datasets/test_dataframe.py index 5f9f7442..37fed088 100644 --- a/tests/tamr_client/datasets/test_dataframe.py +++ b/tests/tamr_client/datasets/test_dataframe.py @@ -15,7 +15,10 @@ def test_upsert(): dataset = utils.dataset() url = tc.URL(path="datasets/1:updateRecords") - updates = utils.records_to_updates(_records_json) + updates = [ + tc.record._create_command(record, primary_key_name="primary_key") + for i, record in enumerate(_records_json, start=1) + ] snoop: Dict = {} responses.add_callback( responses.POST, diff --git a/tests/tamr_client/datasets/test_record.py b/tests/tamr_client/datasets/test_record.py index 7ed0e4d5..c1accef0 100644 --- a/tests/tamr_client/datasets/test_record.py +++ b/tests/tamr_client/datasets/test_record.py @@ -14,7 +14,10 @@ def test_update(): dataset = utils.dataset() url = tc.URL(path="datasets/1:updateRecords") - updates = utils.records_to_updates(_records_json) + updates = [ + tc.record._create_command(record, primary_key_name="primary_key") + for i, record in enumerate(_records_json, start=1) + ] snoop: Dict = {} responses.add_callback( responses.POST, @@ -35,7 +38,10 @@ def test_upsert(): dataset = utils.dataset() url = tc.URL(path="datasets/1:updateRecords") - updates = utils.records_to_updates(_records_json) + updates = [ + tc.record._create_command(record, primary_key_name="primary_key") + for i, record in enumerate(_records_json, start=1) + ] snoop: Dict = {} responses.add_callback( responses.POST, @@ -63,36 +69,16 @@ def test_upsert_primary_key_not_found(): ) -@responses.activate -def test_delete_by_ids(): - s = utils.session() - dataset = utils.dataset() - - url = tc.URL(path="datasets/1:updateRecords") - deletes = utils.records_to_deletes(_records_json) - snoop: Dict = {} - responses.add_callback( - responses.POST, - str(url), - partial( - utils.capture_payload, snoop=snoop, status=200, response_json=_response_json - ), - ) - - ids = (r["primary_key"] for r in _records_json) - - response = tc.record._delete_by_id(s, dataset, ids) - assert response == _response_json - assert snoop["payload"] == utils.stringify(deletes) - - @responses.activate def test_delete(): s = utils.session() dataset = utils.dataset() url = tc.URL(path="datasets/1:updateRecords") - deletes = utils.records_to_deletes(_records_json) + deletes = [ + tc.record._delete_command(record, primary_key_name="primary_key") + for i, record in enumerate(_records_json, start=1) + ] snoop: Dict = {} responses.add_callback( responses.POST, diff --git a/tests/tamr_client/utils.py b/tests/tamr_client/utils.py index 4ce085bf..acaa6f5a 100644 --- a/tests/tamr_client/utils.py +++ b/tests/tamr_client/utils.py @@ -37,18 +37,5 @@ def capture_payload(request, snoop, status, response_json): return status, {}, json.dumps(response_json) -def records_to_deletes(records): - return [ - {"action": "DELETE", "recordId": i} for i, record in enumerate(records, start=1) - ] - - -def records_to_updates(records): - return [ - {"action": "CREATE", "recordId": i, "record": record} - for i, record in enumerate(records, start=1) - ] - - def stringify(updates): return [json.dumps(u) for u in updates] From 327829251abc4f57ba1cd829274cb724ac24ec67 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 27 Mar 2020 11:26:53 -0400 Subject: [PATCH 315/632] Remove unnecessary use of enumerate() in test_dataframe.py and test_record.py. --- tests/tamr_client/datasets/test_dataframe.py | 2 +- tests/tamr_client/datasets/test_record.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/tamr_client/datasets/test_dataframe.py b/tests/tamr_client/datasets/test_dataframe.py index 37fed088..28010189 100644 --- a/tests/tamr_client/datasets/test_dataframe.py +++ b/tests/tamr_client/datasets/test_dataframe.py @@ -17,7 +17,7 @@ def test_upsert(): url = tc.URL(path="datasets/1:updateRecords") updates = [ tc.record._create_command(record, primary_key_name="primary_key") - for i, record in enumerate(_records_json, start=1) + for record in _records_json ] snoop: Dict = {} responses.add_callback( diff --git a/tests/tamr_client/datasets/test_record.py b/tests/tamr_client/datasets/test_record.py index c1accef0..a36ba15f 100644 --- a/tests/tamr_client/datasets/test_record.py +++ b/tests/tamr_client/datasets/test_record.py @@ -16,7 +16,7 @@ def test_update(): url = tc.URL(path="datasets/1:updateRecords") updates = [ tc.record._create_command(record, primary_key_name="primary_key") - for i, record in enumerate(_records_json, start=1) + for record in _records_json ] snoop: Dict = {} responses.add_callback( @@ -40,7 +40,7 @@ def test_upsert(): url = tc.URL(path="datasets/1:updateRecords") updates = [ tc.record._create_command(record, primary_key_name="primary_key") - for i, record in enumerate(_records_json, start=1) + for record in _records_json ] snoop: Dict = {} responses.add_callback( @@ -77,7 +77,7 @@ def test_delete(): url = tc.URL(path="datasets/1:updateRecords") deletes = [ tc.record._delete_command(record, primary_key_name="primary_key") - for i, record in enumerate(_records_json, start=1) + for record in _records_json ] snoop: Dict = {} responses.add_callback( From 4d7af83e18f2c9289160b285a2cbdb801082e619 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 1 Apr 2020 16:01:56 -0400 Subject: [PATCH 316/632] Add pandas stubfile. --- stubs/pandas.pyi | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 stubs/pandas.pyi diff --git a/stubs/pandas.pyi b/stubs/pandas.pyi new file mode 100644 index 00000000..78b2b6b4 --- /dev/null +++ b/stubs/pandas.pyi @@ -0,0 +1,19 @@ +from typing import Any, Dict, Iterator, List, Optional, Tuple + +JsonDict = Dict[str, Any] + +class DataFrame: + columns: Index + + def __init__( + self, + data: List[JsonDict] = None, + ): ... + + def iterrows(self) -> Iterator[Tuple[int, Series]]: ... + +class Series: + def to_json(self) -> str: ... + +class Index: + def __iter__(self) -> Iterator[str]: ... \ No newline at end of file From c4bbb1c58117c4756d7ad929dcc02ed7a171d54f Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 1 Apr 2020 16:33:42 -0400 Subject: [PATCH 317/632] Add add_callback() to responses.pyi stubfile. --- stubs/pandas.pyi | 11 +++-------- stubs/responses.pyi | 2 ++ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/stubs/pandas.pyi b/stubs/pandas.pyi index 78b2b6b4..c7976d53 100644 --- a/stubs/pandas.pyi +++ b/stubs/pandas.pyi @@ -1,19 +1,14 @@ -from typing import Any, Dict, Iterator, List, Optional, Tuple +from typing import Any, Dict, Iterator, List, Tuple JsonDict = Dict[str, Any] class DataFrame: columns: Index - - def __init__( - self, - data: List[JsonDict] = None, - ): ... - + def __init__(self, data: List[JsonDict] = None): ... def iterrows(self) -> Iterator[Tuple[int, Series]]: ... class Series: def to_json(self) -> str: ... class Index: - def __iter__(self) -> Iterator[str]: ... \ No newline at end of file + def __iter__(self) -> Iterator[str]: ... diff --git a/stubs/responses.pyi b/stubs/responses.pyi index adb6753e..b4df44f3 100644 --- a/stubs/responses.pyi +++ b/stubs/responses.pyi @@ -1,3 +1,4 @@ +from functools import partial from typing import Any, Dict, Optional, TypeVar JsonDict = Dict[str, Any] @@ -18,3 +19,4 @@ def add( T = TypeVar("T") def activate(T) -> T: ... +def add_callback(method: Optional[str], url: Optional[str], callback: partial[Any]): ... From d7e87900ade4d20a886af0655c99666a6236df5e Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 1 Apr 2020 16:39:09 -0400 Subject: [PATCH 318/632] Cast stringified updates to IO via typing to pass typecheck. --- tamr_client/datasets/record.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tamr_client/datasets/record.py b/tamr_client/datasets/record.py index 2e5ae07a..6c34e399 100644 --- a/tamr_client/datasets/record.py +++ b/tamr_client/datasets/record.py @@ -5,7 +5,7 @@ underlying _update function can be used directly." """ import json -from typing import Dict, Iterable +from typing import cast, Dict, IO, Iterable import tamr_client as tc from tamr_client.types import JsonDict @@ -35,10 +35,12 @@ def _update( requests.HTTPError: If an HTTP error is encountered """ stringified_updates = (json.dumps(update) for update in updates) + # Requests accepts a generator, but typeshed expects this to be a file-like object + io_updates = cast(IO, stringified_updates) r = session.post( str(dataset.url) + ":updateRecords", headers={"Content-Encoding": "utf-8"}, - data=stringified_updates, + data=io_updates, ) return tc.response.successful(r).json() From 25801c87d1b633ea7785b0d46b414374cf04bad9 Mon Sep 17 00:00:00 2001 From: skalish <39866163+skalish@users.noreply.github.com> Date: Wed, 1 Apr 2020 19:30:31 -0400 Subject: [PATCH 319/632] Update tamr_client/datasets/record.py Co-Authored-By: Pedro Cattori --- tamr_client/datasets/record.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_client/datasets/record.py b/tamr_client/datasets/record.py index 6c34e399..ac3db9b0 100644 --- a/tamr_client/datasets/record.py +++ b/tamr_client/datasets/record.py @@ -35,7 +35,7 @@ def _update( requests.HTTPError: If an HTTP error is encountered """ stringified_updates = (json.dumps(update) for update in updates) - # Requests accepts a generator, but typeshed expects this to be a file-like object + # `requests` accepts a generator for `data` param, but stubs for `requests` in https://github.com/python/typeshed expects this to be a file-like object io_updates = cast(IO, stringified_updates) r = session.post( str(dataset.url) + ":updateRecords", From 9706b14887ef4054575c79e8e12a3f6efdc9ab2a Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 2 Apr 2020 18:07:28 -0400 Subject: [PATCH 320/632] Add graceful handling of primary_key_name in tc.dataframe.upsert and related tests. --- stubs/pandas.pyi | 7 +- tamr_client/datasets/dataframe.py | 41 ++++++++-- tests/tamr_client/datasets/test_dataframe.py | 80 ++++++++++++++++++++ 3 files changed, 119 insertions(+), 9 deletions(-) diff --git a/stubs/pandas.pyi b/stubs/pandas.pyi index c7976d53..e43d7ebe 100644 --- a/stubs/pandas.pyi +++ b/stubs/pandas.pyi @@ -3,12 +3,17 @@ from typing import Any, Dict, Iterator, List, Tuple JsonDict = Dict[str, Any] class DataFrame: + index: Index columns: Index - def __init__(self, data: List[JsonDict] = None): ... + def __init__(self, data: List[JsonDict] = None, index: List[int] = None): ... + def copy(self) -> DataFrame: ... + def drop(self, labels: str, axis: int, inplace: bool): ... + def insert(self, loc: int, column: str, value: Index): ... def iterrows(self) -> Iterator[Tuple[int, Series]]: ... class Series: def to_json(self) -> str: ... class Index: + name: str def __iter__(self) -> Iterator[str]: ... diff --git a/tamr_client/datasets/dataframe.py b/tamr_client/datasets/dataframe.py index 11c08fd9..ba9fba72 100644 --- a/tamr_client/datasets/dataframe.py +++ b/tamr_client/datasets/dataframe.py @@ -3,6 +3,7 @@ """ import json +from typing import Optional import pandas as pd @@ -11,28 +12,52 @@ def upsert( - session: tc.Session, dataset: tc.Dataset, df: pd.DataFrame, *, primary_key_name: str + session: tc.Session, + dataset: tc.Dataset, + df: pd.DataFrame, + *, + primary_key_name: Optional[str] = None, ) -> JsonDict: """Upserts a record for each row of `df` with attributes for each column in `df`. Args: dataset: Dataset to receive record updates df: The DataFrame containing records to be upserted - primary_key_name: The primary key of the dataset. Must be a column of `df`. + primary_key_name: The primary key of the dataset. Must be a column of `df`. By default the key_attribute_name of dataset Returns: - JSON response body from the server + JSON response body from the server Raises: requests.HTTPError: If an HTTP error is encountered - PrimaryKeyNotFound: If `primary_key_name` is not a column in `df`. + PrimaryKeyNotFound: If `primary_key_name` is not a column in `df` or the index of `df` + ValueError: If `primary_key_name` matches both a column in `df` and the index of `df` """ - if primary_key_name not in df.columns: - raise tc.PrimaryKeyNotFound( - f"Primary key: {primary_key_name} is not in DataFrame column names: {df.columns}" + if not primary_key_name: + primary_key_name = dataset.key_attribute_names[0] + + if primary_key_name in df.columns and primary_key_name == df.index.name: + raise ValueError( + f"Index {primary_key_name} has the same name as column {primary_key_name}" ) + + if primary_key_name not in df.columns: + # if `df.index.name` is the primary key, create column from `df.index` + if primary_key_name == df.index.name: + df.insert(0, df.index.name, df.index) + else: + raise tc.PrimaryKeyNotFound( + f"Primary key: {primary_key_name} is not DataFrame index name: {df.index.name} or in DataFrame column names: {df.columns}" + ) # serialize records via to_json to handle `np.nan` values serialized_records = (x[1].to_json() for x in df.iterrows()) records = (json.loads(x) for x in serialized_records) - return tc.datasets.record.upsert( + + response = tc.datasets.record.upsert( session, dataset, records, primary_key_name=primary_key_name ) + + # if index was used as the primary key, drop column that was created + if primary_key_name == df.index.name: + df.drop(primary_key_name, axis=1, inplace=True) + + return response diff --git a/tests/tamr_client/datasets/test_dataframe.py b/tests/tamr_client/datasets/test_dataframe.py index 28010189..974cbe45 100644 --- a/tests/tamr_client/datasets/test_dataframe.py +++ b/tests/tamr_client/datasets/test_dataframe.py @@ -46,8 +46,88 @@ def test_upsert_primary_key_not_found(): tc.dataframe.upsert(s, dataset, df, primary_key_name="wrong_primary_key") +@responses.activate +def test_upsert_infer_primary_key(): + s = utils.session() + dataset = utils.dataset() + + url = tc.URL(path="datasets/1:updateRecords") + updates = [ + tc.record._create_command(record, primary_key_name="primary_key") + for record in _records_json + ] + snoop: Dict = {} + responses.add_callback( + responses.POST, + str(url), + partial( + utils.capture_payload, snoop=snoop, status=200, response_json=_response_json + ), + ) + + df = pd.DataFrame(_records_json) + + response = tc.dataframe.upsert(s, dataset, df) + assert response == _response_json + assert snoop["payload"] == utils.stringify(updates) + + +@responses.activate +def test_upsert_index_as_primary_key(): + s = utils.session() + dataset = utils.dataset() + + url = tc.URL(path="datasets/1:updateRecords") + updates = [ + tc.record._create_command(record, primary_key_name="primary_key") + for record in _records_with_keys_json_2 + ] + snoop: Dict = {} + responses.add_callback( + responses.POST, + str(url), + partial( + utils.capture_payload, snoop=snoop, status=200, response_json=_response_json + ), + ) + + df = pd.DataFrame( + _records_json_2, + index=[record["primary_key"] for record in _records_with_keys_json_2], + ) + df.index.name = "primary_key" + df_original = df.copy() + + response = tc.dataframe.upsert(s, dataset, df, primary_key_name="primary_key") + assert response == _response_json + assert snoop["payload"] == utils.stringify(updates) + assert df.columns == df_original.columns + + +@responses.activate +def test_upsert_index_column_name_collision(): + s = utils.session() + dataset = utils.dataset() + + df = pd.DataFrame(_records_json_2) + df.index.name = "primary_key" + + # create column in `df` with same name as index and matching "primary_key" + df.insert(0, df.index.name, df.index) + + with pytest.raises(ValueError): + tc.dataframe.upsert(s, dataset, df, primary_key_name="primary_key") + + _records_json = [{"primary_key": 1}, {"primary_key": 2}] +_records_json_2 = [{"attribute": 1}, {"attribute": 2}] + +_records_with_keys_json_2 = [ + {"primary_key": 1, "attribute": 1}, + {"primary_key": 2, "attribute": 2}, +] + _response_json = { "numCommandsProcessed": 2, "allCommandsSucceeded": True, From 8a2f36c7e97c55a8f4bb4ce79764f55e2107166e Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 2 Apr 2020 19:33:52 -0400 Subject: [PATCH 321/632] Change method of using index as primary key in tc.dataframe.upsert(). --- stubs/pandas.pyi | 2 +- tamr_client/datasets/dataframe.py | 30 +++++++++----------- tests/tamr_client/datasets/test_dataframe.py | 2 -- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/stubs/pandas.pyi b/stubs/pandas.pyi index e43d7ebe..94bffaca 100644 --- a/stubs/pandas.pyi +++ b/stubs/pandas.pyi @@ -6,10 +6,10 @@ class DataFrame: index: Index columns: Index def __init__(self, data: List[JsonDict] = None, index: List[int] = None): ... - def copy(self) -> DataFrame: ... def drop(self, labels: str, axis: int, inplace: bool): ... def insert(self, loc: int, column: str, value: Index): ... def iterrows(self) -> Iterator[Tuple[int, Series]]: ... + def set_index(self, keys: str) -> DataFrame: ... class Series: def to_json(self) -> str: ... diff --git a/tamr_client/datasets/dataframe.py b/tamr_client/datasets/dataframe.py index ba9fba72..8765d0c6 100644 --- a/tamr_client/datasets/dataframe.py +++ b/tamr_client/datasets/dataframe.py @@ -40,24 +40,20 @@ def upsert( f"Index {primary_key_name} has the same name as column {primary_key_name}" ) - if primary_key_name not in df.columns: - # if `df.index.name` is the primary key, create column from `df.index` - if primary_key_name == df.index.name: - df.insert(0, df.index.name, df.index) - else: - raise tc.PrimaryKeyNotFound( - f"Primary key: {primary_key_name} is not DataFrame index name: {df.index.name} or in DataFrame column names: {df.columns}" - ) # serialize records via to_json to handle `np.nan` values - serialized_records = (x[1].to_json() for x in df.iterrows()) - records = (json.loads(x) for x in serialized_records) + if primary_key_name == df.index.name: + serialized_records = ((pk, row.to_json()) for pk, row in df.iterrows()) + elif primary_key_name in df.columns: + index_df = df.set_index(primary_key_name) + serialized_records = ((pk, row.to_json()) for pk, row in index_df.iterrows()) + else: + raise tc.PrimaryKeyNotFound( + f"Primary key: {primary_key_name} is not DataFrame index name: {df.index.name} or in DataFrame column names: {df.columns}" + ) - response = tc.datasets.record.upsert( + records = ( + {primary_key_name: pk, **json.loads(row)} for pk, row in serialized_records + ) + return tc.datasets.record.upsert( session, dataset, records, primary_key_name=primary_key_name ) - - # if index was used as the primary key, drop column that was created - if primary_key_name == df.index.name: - df.drop(primary_key_name, axis=1, inplace=True) - - return response diff --git a/tests/tamr_client/datasets/test_dataframe.py b/tests/tamr_client/datasets/test_dataframe.py index 974cbe45..90d8ab5a 100644 --- a/tests/tamr_client/datasets/test_dataframe.py +++ b/tests/tamr_client/datasets/test_dataframe.py @@ -96,12 +96,10 @@ def test_upsert_index_as_primary_key(): index=[record["primary_key"] for record in _records_with_keys_json_2], ) df.index.name = "primary_key" - df_original = df.copy() response = tc.dataframe.upsert(s, dataset, df, primary_key_name="primary_key") assert response == _response_json assert snoop["payload"] == utils.stringify(updates) - assert df.columns == df_original.columns @responses.activate From 341a1f5842e95ce42c733b3aff376ab04eb99d96 Mon Sep 17 00:00:00 2001 From: skalish <39866163+skalish@users.noreply.github.com> Date: Thu, 2 Apr 2020 22:30:43 -0400 Subject: [PATCH 322/632] Explicit check on `primary_key_name` is None Co-Authored-By: Pedro Cattori --- tamr_client/datasets/dataframe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_client/datasets/dataframe.py b/tamr_client/datasets/dataframe.py index 8765d0c6..d68b1b30 100644 --- a/tamr_client/datasets/dataframe.py +++ b/tamr_client/datasets/dataframe.py @@ -32,7 +32,7 @@ def upsert( PrimaryKeyNotFound: If `primary_key_name` is not a column in `df` or the index of `df` ValueError: If `primary_key_name` matches both a column in `df` and the index of `df` """ - if not primary_key_name: + if primary_key_name is None: primary_key_name = dataset.key_attribute_names[0] if primary_key_name in df.columns and primary_key_name == df.index.name: From 110f1478bfbdc4d43a12281943b977b9bcd7ac8a Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 3 Apr 2020 10:09:50 -0400 Subject: [PATCH 323/632] Update CHANGELOG.md --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65f648a4..cf1516fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,11 @@ - `tc.response` module - functions: `successful`, `ndjson` - [#35](https://github.com/Datatamer/tamr-client/issues/35) projects.by_name() functionality added. Can now fetch a project by its name. + - BETA: New record upsert, delete, upsert from DataFrame functionality! + - `tc.record` module + - functions: `tc.record.upsert`, `tc.record.delete` + - `tc.dataframe` module + - functions: `tc.dataframe.upsert` **BUG FIXES** - Links from our docs to the `requests` docs were outdated. Links have been updated to point to the new `requests` docs URL. - [#323](https://github.com/Datatamer/tamr-client/issues/323) Documentation for setting `dtype=str` before calling `client.datasets.create_from_dataframe` From 742b9b6a3ceef9fc46ebe2dc9dfa18c72d9a2564 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 3 Apr 2020 10:34:32 -0400 Subject: [PATCH 324/632] Define and use new exception AmbiguousPrimaryKey. --- tamr_client/__init__.py | 1 + tamr_client/datasets/dataframe.py | 8 +++++++- tests/tamr_client/datasets/test_dataframe.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 19c273a5..a2afe254 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -62,4 +62,5 @@ import tamr_client.datasets.record as record # dataframe +from tamr_client.datasets.dataframe import AmbiguousPrimaryKey import tamr_client.datasets.dataframe as dataframe diff --git a/tamr_client/datasets/dataframe.py b/tamr_client/datasets/dataframe.py index d68b1b30..bb33de18 100644 --- a/tamr_client/datasets/dataframe.py +++ b/tamr_client/datasets/dataframe.py @@ -11,6 +11,12 @@ from tamr_client.types import JsonDict +class AmbiguousPrimaryKey(Exception): + """Raised when referencing a primary key by name that matches multiple possible targets.""" + + pass + + def upsert( session: tc.Session, dataset: tc.Dataset, @@ -36,7 +42,7 @@ def upsert( primary_key_name = dataset.key_attribute_names[0] if primary_key_name in df.columns and primary_key_name == df.index.name: - raise ValueError( + raise AmbiguousPrimaryKey( f"Index {primary_key_name} has the same name as column {primary_key_name}" ) diff --git a/tests/tamr_client/datasets/test_dataframe.py b/tests/tamr_client/datasets/test_dataframe.py index 90d8ab5a..408c070d 100644 --- a/tests/tamr_client/datasets/test_dataframe.py +++ b/tests/tamr_client/datasets/test_dataframe.py @@ -113,7 +113,7 @@ def test_upsert_index_column_name_collision(): # create column in `df` with same name as index and matching "primary_key" df.insert(0, df.index.name, df.index) - with pytest.raises(ValueError): + with pytest.raises(tc.AmbiguousPrimaryKey): tc.dataframe.upsert(s, dataset, df, primary_key_name="primary_key") From 8a1511b6f381536d8c906a6648d745aeea6ac163 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 3 Apr 2020 11:42:21 -0400 Subject: [PATCH 325/632] Change code style in dataframe.upsert to check preconditions before operations. --- tamr_client/datasets/dataframe.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tamr_client/datasets/dataframe.py b/tamr_client/datasets/dataframe.py index bb33de18..112082ad 100644 --- a/tamr_client/datasets/dataframe.py +++ b/tamr_client/datasets/dataframe.py @@ -41,22 +41,22 @@ def upsert( if primary_key_name is None: primary_key_name = dataset.key_attribute_names[0] + # preconditions if primary_key_name in df.columns and primary_key_name == df.index.name: raise AmbiguousPrimaryKey( f"Index {primary_key_name} has the same name as column {primary_key_name}" ) - - # serialize records via to_json to handle `np.nan` values - if primary_key_name == df.index.name: - serialized_records = ((pk, row.to_json()) for pk, row in df.iterrows()) - elif primary_key_name in df.columns: - index_df = df.set_index(primary_key_name) - serialized_records = ((pk, row.to_json()) for pk, row in index_df.iterrows()) - else: + elif primary_key_name not in df.columns and primary_key_name != df.index.name: raise tc.PrimaryKeyNotFound( f"Primary key: {primary_key_name} is not DataFrame index name: {df.index.name} or in DataFrame column names: {df.columns}" ) + # promote primary key column to index + if primary_key_name in df.columns: + df = df.set_index(primary_key_name) + + # serialize records via to_json to handle `np.nan` values + serialized_records = ((pk, row.to_json()) for pk, row in df.iterrows()) records = ( {primary_key_name: pk, **json.loads(row)} for pk, row in serialized_records ) From abc6be360574ff6098914b503afa57732ab4c9c0 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 3 Apr 2020 13:43:46 -0400 Subject: [PATCH 326/632] Add primary_key_name inferred from dataset functionality to tc.record.upsert and tc.record.delete. --- tamr_client/datasets/record.py | 18 ++++++--- tests/tamr_client/datasets/test_record.py | 48 +++++++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/tamr_client/datasets/record.py b/tamr_client/datasets/record.py index ac3db9b0..bb8baa81 100644 --- a/tamr_client/datasets/record.py +++ b/tamr_client/datasets/record.py @@ -5,7 +5,7 @@ underlying _update function can be used directly." """ import json -from typing import cast, Dict, IO, Iterable +from typing import cast, Dict, IO, Iterable, Optional import tamr_client as tc from tamr_client.types import JsonDict @@ -50,14 +50,15 @@ def upsert( dataset: tc.Dataset, records: Iterable[Dict], *, - primary_key_name: str, + primary_key_name: Optional[str] = None, ) -> JsonDict: """Create or update the specified records. Args: dataset: Dataset to receive record updates records: The records to update, as dictionaries - primary_key_name: The primary key for these records, which must be a key in each record dictionary + primary_key_name: The primary key for these records, which must be a key in each record dictionary. + By default the key_attribute_name of dataset Returns: JSON response body from server @@ -67,6 +68,9 @@ def upsert( PrimaryKeyNotFound: If primary_key_name does not match dataset primary key PrimaryKeyNotFound: If primary_key_name not in a record dictionary """ + if primary_key_name is None: + primary_key_name = dataset.key_attribute_names[0] + if primary_key_name not in dataset.key_attribute_names: raise PrimaryKeyNotFound( f"Primary key: {primary_key_name} is not in dataset key attribute names: {dataset.key_attribute_names}" @@ -82,14 +86,15 @@ def delete( dataset: tc.Dataset, records: Iterable[Dict], *, - primary_key_name: str, + primary_key_name: Optional[str] = None, ) -> JsonDict: """Deletes the specified records, based on primary key values. Does not check that other attribute values match. Args: dataset: Dataset from which to delete records records: The records to update, as dictionaries - primary_key_name: The primary key for these records, which must be a key in each record dictionary + primary_key_name: The primary key for these records, which must be a key in each record dictionary. + By default the key_attribute_name of dataset Returns: JSON response body from server @@ -99,6 +104,9 @@ def delete( PrimaryKeyNotFound: If primary_key_name does not match dataset primary key PrimaryKeyNotFound: If primary_key_name not in a record dictionary """ + if primary_key_name is None: + primary_key_name = dataset.key_attribute_names[0] + if primary_key_name not in dataset.key_attribute_names: raise PrimaryKeyNotFound( f"Primary key: {primary_key_name} is not in dataset key attribute names: {dataset.key_attribute_names}" diff --git a/tests/tamr_client/datasets/test_record.py b/tests/tamr_client/datasets/test_record.py index a36ba15f..18111310 100644 --- a/tests/tamr_client/datasets/test_record.py +++ b/tests/tamr_client/datasets/test_record.py @@ -69,6 +69,30 @@ def test_upsert_primary_key_not_found(): ) +@responses.activate +def test_upsert_infer_primary_key(): + s = utils.session() + dataset = utils.dataset() + + url = tc.URL(path="datasets/1:updateRecords") + updates = [ + tc.record._create_command(record, primary_key_name="primary_key") + for record in _records_json + ] + snoop: Dict = {} + responses.add_callback( + responses.POST, + str(url), + partial( + utils.capture_payload, snoop=snoop, status=200, response_json=_response_json + ), + ) + + response = tc.record.upsert(s, dataset, _records_json) + assert response == _response_json + assert snoop["payload"] == utils.stringify(updates) + + @responses.activate def test_delete(): s = utils.session() @@ -106,6 +130,30 @@ def test_delete_primary_key_not_found(): ) +@responses.activate +def test_delete_infer_primary_key(): + s = utils.session() + dataset = utils.dataset() + + url = tc.URL(path="datasets/1:updateRecords") + deletes = [ + tc.record._delete_command(record, primary_key_name="primary_key") + for record in _records_json + ] + snoop: Dict = {} + responses.add_callback( + responses.POST, + str(url), + partial( + utils.capture_payload, snoop=snoop, status=200, response_json=_response_json + ), + ) + + response = tc.record.delete(s, dataset, _records_json) + assert response == _response_json + assert snoop["payload"] == utils.stringify(deletes) + + _records_json = [{"primary_key": 1}, {"primary_key": 2}] _response_json = { From a60b5206bece9d26cb1c8905b5a68bbc12d667c0 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 21 Feb 2020 14:06:46 -0500 Subject: [PATCH 327/632] Add dataset method .upsert_from_dataframe(). --- tamr_unify_client/dataset/resource.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index 3f3eb36c..5805fbc1 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -84,6 +84,25 @@ def _update_records(self, updates, **json_args): .json() ) + def upsert_from_dataframe(self, df, primary_key_name, ignore_nan=True): + """Upserts a record for each row of `df` with attributes for each column in `df`. + + :param df: The data to upsert records from. + :type df: :class:`pandas.DataFrame` + :param primary_key_name: The name of the primary key of the dataset. Must be a column of `df`. + :type primary_key_name: str + :param ignore_nan: Whether to convert `NaN` values to `null` before upserting records to Tamr. If `False` and `NaN` is in `df`, this function will fail. Optional, default is `True`. + :type ignore_nan: bool + :returns: JSON response body from the server. + :rtype: dict + :raises KeyError: If `primary_key_name` is not a column in `df`. + """ + if primary_key_name not in df.columns: + raise KeyError(f"{primary_key_name} is not an attribute of the data") + + records = df.to_dict(orient="records") + return self.upsert_records(records, primary_key_name, ignore_nan=ignore_nan) + def upsert_records(self, records, primary_key_name, **json_args): """Creates or updates the specified records. From 8c2ba38118159f2faee0a54b19fb5e1fbf0064b1 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 21 Feb 2020 14:19:13 -0500 Subject: [PATCH 328/632] Add test_upsert_from_dataframe() to test_dataset_records.py. --- tests/unit/test_dataset_records.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/unit/test_dataset_records.py b/tests/unit/test_dataset_records.py index 2a886bf8..b09f35a3 100644 --- a/tests/unit/test_dataset_records.py +++ b/tests/unit/test_dataset_records.py @@ -1,6 +1,7 @@ from functools import partial from unittest import TestCase +from pandas import DataFrame from requests.exceptions import HTTPError import responses import simplejson @@ -99,6 +100,26 @@ def create_callback(request, snoop): self.assertEqual(response, self._response_json) self.assertEqual(snoop["payload"], TestDatasetRecords.stringify(updates, False)) + @responses.activate + def test_upsert_from_dataframe(self): + def create_callback(request, snoop): + snoop["payload"] = list(request.body) + return 200, {}, simplejson.dumps(self._response_json) + + responses.add(responses.GET, self._dataset_url, json={}) + dataset = self.tamr.datasets.by_resource_id(self._dataset_id) + + records_url = f"{self._dataset_url}:updateRecords" + updates = TestDatasetRecords.records_to_updates(self._records_json) + snoop = {} + responses.add_callback( + responses.POST, records_url, partial(create_callback, snoop=snoop) + ) + + response = dataset.upsert_from_dataframe(self._dataframe, "attribute1") + self.assertEqual(response, self._response_json) + self.assertEqual(snoop["payload"], TestDatasetRecords.stringify(updates, False)) + @responses.activate def test_delete(self): def create_callback(request, snoop): @@ -173,6 +194,7 @@ def stringify(updates, ignore_nan): _dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/{_dataset_id}" _records_json = [{"attribute1": 1}, {"attribute1": 2}] + _dataframe = DataFrame(_records_json, columns=["attribute1"]) _nan_records_json = [{"attribute1": float("nan")}, {"attribute1": float("nan")}] _response_json = { "numCommandsProcessed": 2, From 3594aa396860dd144ea15b1e06ca7f76a23b1ea5 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 21 Feb 2020 17:14:58 -0500 Subject: [PATCH 329/632] Change docstrings to Google-style. --- tamr_unify_client/dataset/resource.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index 5805fbc1..afbb7841 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -1,5 +1,6 @@ from copy import deepcopy +import pandas import simplejson as json from tamr_unify_client.attribute.collection import AttributeCollection @@ -84,18 +85,22 @@ def _update_records(self, updates, **json_args): .json() ) - def upsert_from_dataframe(self, df, primary_key_name, ignore_nan=True): + def upsert_from_dataframe( + self, df: pandas.DataFrame, primary_key_name: str, ignore_nan: bool = True + ) -> dict: """Upserts a record for each row of `df` with attributes for each column in `df`. - :param df: The data to upsert records from. - :type df: :class:`pandas.DataFrame` - :param primary_key_name: The name of the primary key of the dataset. Must be a column of `df`. - :type primary_key_name: str - :param ignore_nan: Whether to convert `NaN` values to `null` before upserting records to Tamr. If `False` and `NaN` is in `df`, this function will fail. Optional, default is `True`. - :type ignore_nan: bool - :returns: JSON response body from the server. - :rtype: dict - :raises KeyError: If `primary_key_name` is not a column in `df`. + Args: + df (pandas.DataFrame): The data to upsert records from. + primary_key_name (str): The name of the primary key of the dataset. Must be a column of `df`. + ignore_nan (bool): Whether to convert `NaN` values to `null` before upserting records to Tamr. If `False` and `NaN` is in `df`, this function will fail. Optional, default is `True`. + + Returns: + dict: JSON response body from the server. + + Raises: + KeyError: If `primary_key_name` is not a column in `df`. + """ if primary_key_name not in df.columns: raise KeyError(f"{primary_key_name} is not an attribute of the data") From 62251a9d97466b456604a18a48b6843ca8059efe Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 21 Feb 2020 17:16:15 -0500 Subject: [PATCH 330/632] Enforce keyword-specified arguments after df in upsert_from_dataframe(). --- tamr_unify_client/dataset/resource.py | 2 +- tests/unit/test_dataset_records.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index afbb7841..8bc040ea 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -86,7 +86,7 @@ def _update_records(self, updates, **json_args): ) def upsert_from_dataframe( - self, df: pandas.DataFrame, primary_key_name: str, ignore_nan: bool = True + self, df: pandas.DataFrame, *, primary_key_name: str, ignore_nan: bool = True ) -> dict: """Upserts a record for each row of `df` with attributes for each column in `df`. diff --git a/tests/unit/test_dataset_records.py b/tests/unit/test_dataset_records.py index b09f35a3..6484e007 100644 --- a/tests/unit/test_dataset_records.py +++ b/tests/unit/test_dataset_records.py @@ -116,7 +116,9 @@ def create_callback(request, snoop): responses.POST, records_url, partial(create_callback, snoop=snoop) ) - response = dataset.upsert_from_dataframe(self._dataframe, "attribute1") + response = dataset.upsert_from_dataframe( + self._dataframe, primary_key_name="attribute1" + ) self.assertEqual(response, self._response_json) self.assertEqual(snoop["payload"], TestDatasetRecords.stringify(updates, False)) From 4a49fa3a0d83cf31763f5d3f3c0c61ad52173578 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 21 Feb 2020 22:55:41 -0500 Subject: [PATCH 331/632] Remove repetition of type information in Args and Returns sections. --- tamr_unify_client/dataset/resource.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index 8bc040ea..77d0525c 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -1,6 +1,6 @@ from copy import deepcopy -import pandas +import pandas as pd import simplejson as json from tamr_unify_client.attribute.collection import AttributeCollection @@ -86,17 +86,17 @@ def _update_records(self, updates, **json_args): ) def upsert_from_dataframe( - self, df: pandas.DataFrame, *, primary_key_name: str, ignore_nan: bool = True + self, df: pd.DataFrame, *, primary_key_name: str, ignore_nan: bool = True ) -> dict: """Upserts a record for each row of `df` with attributes for each column in `df`. Args: - df (pandas.DataFrame): The data to upsert records from. - primary_key_name (str): The name of the primary key of the dataset. Must be a column of `df`. - ignore_nan (bool): Whether to convert `NaN` values to `null` before upserting records to Tamr. If `False` and `NaN` is in `df`, this function will fail. Optional, default is `True`. + df: The data to upsert records from. + primary_key_name: The name of the primary key of the dataset. Must be a column of `df`. + ignore_nan: Whether to convert `NaN` values to `null` before upserting records to Tamr. If `False` and `NaN` is in `df`, this function will fail. Optional, default is `True`. Returns: - dict: JSON response body from the server. + JSON response body from the server. Raises: KeyError: If `primary_key_name` is not a column in `df`. From b21b8d5333148e438dee28eee8ec5764cef30956 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 3 Apr 2020 15:08:28 -0400 Subject: [PATCH 332/632] Add change to CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf1516fe..0e0a1b44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ - functions: `tc.record.upsert`, `tc.record.delete` - `tc.dataframe` module - functions: `tc.dataframe.upsert` + - [#377](https://github.com/Datatamer/tamr-client/issues/377) dataset.upsert_from_dataframe() functionality added. Can now upsert records from a pandas DataFrame. **BUG FIXES** - Links from our docs to the `requests` docs were outdated. Links have been updated to point to the new `requests` docs URL. - [#323](https://github.com/Datatamer/tamr-client/issues/323) Documentation for setting `dtype=str` before calling `client.datasets.create_from_dataframe` From 2f2a94f226eb5e65632383c9811beadb71a2d4be Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 3 Apr 2020 17:39:43 -0400 Subject: [PATCH 333/632] Consolidate BETA features into new BETA section of the changelog --- CHANGELOG.md | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e0a1b44..a283826d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ ## 0.11.0-dev - **NEW FEATURES** - - BETA: New attributes package! + **BETA** + Important: Do not use BETA features for production workflows. + + New `tamr_client` package includes: + + - attributes - `tc.attribute` module - `tc.Attribute` type - functions: `from_resource_id`, `from_dataset_all`, `to_json`, `create`, `update`, `delete` @@ -15,14 +19,18 @@ - `tc.attributes.type_alias` module - `tc.attributes.type_alias.DEFAULT` type - `tc.attributes.type_alias.GEOSPATIAL` type - - BETA: New datasets package! + - datasets - `tc.dataset` module - `tc.Dataset` type - functions: `from_resource_id` - - BETA: New `tc.instance` module! + - `tc.instance` module - `tc.Instance` type - functions: `tc.instance.from_auth` - - BETA: New supporting modules! + - `tc.record` module + - functions: `tc.record.upsert`, `tc.record.delete` + - `tc.dataframe` module + - functions: `tc.dataframe.upsert` + - other supporting modules - `tc.auth` module - `tc.UsernamePasswordAuth` type - `tc.session` module @@ -32,13 +40,11 @@ - `tc.URL` type - `tc.response` module - functions: `successful`, `ndjson` + + **NEW FEATURES** - [#35](https://github.com/Datatamer/tamr-client/issues/35) projects.by_name() functionality added. Can now fetch a project by its name. - - BETA: New record upsert, delete, upsert from DataFrame functionality! - - `tc.record` module - - functions: `tc.record.upsert`, `tc.record.delete` - - `tc.dataframe` module - - functions: `tc.dataframe.upsert` - [#377](https://github.com/Datatamer/tamr-client/issues/377) dataset.upsert_from_dataframe() functionality added. Can now upsert records from a pandas DataFrame. + **BUG FIXES** - Links from our docs to the `requests` docs were outdated. Links have been updated to point to the new `requests` docs URL. - [#323](https://github.com/Datatamer/tamr-client/issues/323) Documentation for setting `dtype=str` before calling `client.datasets.create_from_dataframe` From 497449c528662605024a4631104d8827852a52d7 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 3 Apr 2020 17:44:13 -0400 Subject: [PATCH 334/632] CHANGELOG formatting --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a283826d..ecc155c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## 0.11.0-dev **BETA** + Important: Do not use BETA features for production workflows. New `tamr_client` package includes: From c3dff67e3c08a2c30c9e151212351149d3adf267 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Fri, 3 Apr 2020 17:46:46 -0400 Subject: [PATCH 335/632] Group tc.record and tc.dataframe under datasets package --- CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecc155c8..74c599de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## 0.11.0-dev **BETA** - + Important: Do not use BETA features for production workflows. New `tamr_client` package includes: @@ -24,13 +24,13 @@ - `tc.dataset` module - `tc.Dataset` type - functions: `from_resource_id` + - `tc.record` module + - functions: `tc.record.upsert`, `tc.record.delete` + - `tc.dataframe` module + - functions: `tc.dataframe.upsert` - `tc.instance` module - `tc.Instance` type - functions: `tc.instance.from_auth` - - `tc.record` module - - functions: `tc.record.upsert`, `tc.record.delete` - - `tc.dataframe` module - - functions: `tc.dataframe.upsert` - other supporting modules - `tc.auth` module - `tc.UsernamePasswordAuth` type From fa654a036c9a1b9f826d6d31d0237ddbf318b254 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Sun, 5 Apr 2020 15:05:15 -0400 Subject: [PATCH 336/632] Version bump for release See https://github.com/Datatamer/tamr-client/blob/master/RELEASE.md#1-version-bump-on-master --- CHANGELOG.md | 4 +++- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74c599de..cb2f1755 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ -## 0.11.0-dev +## 0.12.0-dev + +## 0.11.0 **BETA** Important: Do not use BETA features for production workflows. diff --git a/pyproject.toml b/pyproject.toml index 48330e10..48cf3c6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "tamr-unify-client" -version = "0.11.0-dev" +version = "0.12.0-dev" description = "Python Client for the Tamr API" license = "Apache-2.0" authors = ["Pedro Cattori "] From c05d8716c756a2a1478f3968fa81c9ff8fa66cb5 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Sun, 5 Apr 2020 15:55:21 -0400 Subject: [PATCH 337/632] Mirror RTD's CI for building docs RTD does not support poetry and needs to install dependencies via pip. Previously, our CI used poetry for building docs, but this led to situations where our CI would pass for docs, but then building docs on RTD would break. See: https://github.com/readthedocs/readthedocs.org/issues/4912 --- .github/workflows/ci.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac403b8b..f1146a43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,9 +69,10 @@ jobs: uses: actions/setup-python@v1.1.1 with: python-version: 3.6 - - name: Install Poetry - uses: dschep/install-poetry-action@v1.2 + # RTD uses pip for managing dependencies, so we mirror that approach - name: Install dependencies - run: poetry install + run: | + python -m pip install . + python -m pip install -r docs/requirements.txt - name: Build docs - run: poetry run invoke docs + run: TAMR_CLIENT_BETA=1 sphinx-build -b html docs docs/_build -W From 2f02f4abbd1a81a627601a95601240f0c7d16204 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Sun, 5 Apr 2020 16:00:38 -0400 Subject: [PATCH 338/632] Promote pandas to direct dependency ...and require Python 3.6.1+ to be compatible with pandas 1.0+ --- poetry.lock | 97 ++++++++++++++++++++++++-------------------------- pyproject.toml | 4 +-- 2 files changed, 49 insertions(+), 52 deletions(-) diff --git a/poetry.lock b/poetry.lock index 9655d37c..ae7c1a31 100644 --- a/poetry.lock +++ b/poetry.lock @@ -272,12 +272,12 @@ python-versions = "*" version = "0.4.3" [[package]] -category = "dev" +category = "main" description = "NumPy is the fundamental package for array computing with Python." name = "numpy" optional = false python-versions = ">=3.5" -version = "1.17.4" +version = "1.18.2" [[package]] category = "dev" @@ -292,12 +292,12 @@ pyparsing = ">=2.0.2" six = "*" [[package]] -category = "dev" +category = "main" description = "Powerful data structures for data analysis, time series, and statistics" name = "pandas" optional = false -python-versions = ">=3.5.3" -version = "0.25.3" +python-versions = ">=3.6.1" +version = "1.0.3" [package.dependencies] numpy = ">=1.13.3" @@ -390,7 +390,7 @@ checkqa-mypy = ["mypy (v0.761)"] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] [[package]] -category = "dev" +category = "main" description = "Extensions to the standard Python datetime module" name = "python-dateutil" optional = false @@ -401,7 +401,7 @@ version = "2.8.1" six = ">=1.5" [[package]] -category = "dev" +category = "main" description = "World timezone definitions, modern and historical" name = "pytz" optional = false @@ -463,7 +463,7 @@ python-versions = ">=2.5, !=3.0.*, !=3.1.*, !=3.2.*" version = "3.16.0" [[package]] -category = "dev" +category = "main" description = "Python 2 and 3 compatibility utilities" name = "six" optional = false @@ -663,8 +663,8 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["pathlib2", "contextlib2", "unittest2"] [metadata] -content-hash = "bd87d3d5f8ec082962b9a470e5fd95c70886d5a3435e320fe806666e696d5396" -python-versions = "^3.6" +content-hash = "2ae183c44ed21ff4a404614166c233a4b27517026448e57a30129486edcbf922" +python-versions = "^3.6.1" [metadata.files] alabaster = [ @@ -815,52 +815,49 @@ mypy-extensions = [ {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] numpy = [ - {file = "numpy-1.17.4-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:ede47b98de79565fcd7f2decb475e2dcc85ee4097743e551fe26cfc7eb3ff143"}, - {file = "numpy-1.17.4-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:43bb4b70585f1c2d153e45323a886839f98af8bfa810f7014b20be714c37c447"}, - {file = "numpy-1.17.4-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c7354e8f0eca5c110b7e978034cd86ed98a7a5ffcf69ca97535445a595e07b8e"}, - {file = "numpy-1.17.4-cp35-cp35m-win32.whl", hash = "sha256:64874913367f18eb3013b16123c9fed113962e75d809fca5b78ebfbb73ed93ba"}, - {file = "numpy-1.17.4-cp35-cp35m-win_amd64.whl", hash = "sha256:6ca4000c4a6f95a78c33c7dadbb9495c10880be9c89316aa536eac359ab820ae"}, - {file = "numpy-1.17.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:75fd817b7061f6378e4659dd792c84c0b60533e867f83e0d1e52d5d8e53df88c"}, - {file = "numpy-1.17.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:7d81d784bdbed30137aca242ab307f3e65c8d93f4c7b7d8f322110b2e90177f9"}, - {file = "numpy-1.17.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe39f5fd4103ec4ca3cb8600b19216cd1ff316b4990f4c0b6057ad982c0a34d5"}, - {file = "numpy-1.17.4-cp36-cp36m-win32.whl", hash = "sha256:e467c57121fe1b78a8f68dd9255fbb3bb3f4f7547c6b9e109f31d14569f490c3"}, - {file = "numpy-1.17.4-cp36-cp36m-win_amd64.whl", hash = "sha256:8d0af8d3664f142414fd5b15cabfd3b6cc3ef242a3c7a7493257025be5a6955f"}, - {file = "numpy-1.17.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9679831005fb16c6df3dd35d17aa31dc0d4d7573d84f0b44cc481490a65c7725"}, - {file = "numpy-1.17.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:acbf5c52db4adb366c064d0b7c7899e3e778d89db585feadd23b06b587d64761"}, - {file = "numpy-1.17.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:3d52298d0be333583739f1aec9026f3b09fdfe3ddf7c7028cb16d9d2af1cca7e"}, - {file = "numpy-1.17.4-cp37-cp37m-win32.whl", hash = "sha256:475963c5b9e116c38ad7347e154e5651d05a2286d86455671f5b1eebba5feb76"}, - {file = "numpy-1.17.4-cp37-cp37m-win_amd64.whl", hash = "sha256:0c0763787133dfeec19904c22c7e358b231c87ba3206b211652f8cbe1241deb6"}, - {file = "numpy-1.17.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:683828e50c339fc9e68720396f2de14253992c495fdddef77a1e17de55f1decc"}, - {file = "numpy-1.17.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e2e9d8c87120ba2c591f60e32736b82b67f72c37ba88a4c23c81b5b8fa49c018"}, - {file = "numpy-1.17.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:a8f67ebfae9f575d85fa859b54d3bdecaeece74e3274b0b5c5f804d7ca789fe1"}, - {file = "numpy-1.17.4-cp38-cp38-win32.whl", hash = "sha256:0a7a1dd123aecc9f0076934288ceed7fd9a81ba3919f11a855a7887cbe82a02f"}, - {file = "numpy-1.17.4-cp38-cp38-win_amd64.whl", hash = "sha256:ada4805ed51f5bcaa3a06d3dd94939351869c095e30a2b54264f5a5004b52170"}, - {file = "numpy-1.17.4.zip", hash = "sha256:f58913e9227400f1395c7b800503ebfdb0772f1c33ff8cb4d6451c06cabdf316"}, + {file = "numpy-1.18.2-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a1baa1dc8ecd88fb2d2a651671a84b9938461e8a8eed13e2f0a812a94084d1fa"}, + {file = "numpy-1.18.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a244f7af80dacf21054386539699ce29bcc64796ed9850c99a34b41305630286"}, + {file = "numpy-1.18.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:6fcc5a3990e269f86d388f165a089259893851437b904f422d301cdce4ff25c8"}, + {file = "numpy-1.18.2-cp35-cp35m-win32.whl", hash = "sha256:b5ad0adb51b2dee7d0ee75a69e9871e2ddfb061c73ea8bc439376298141f77f5"}, + {file = "numpy-1.18.2-cp35-cp35m-win_amd64.whl", hash = "sha256:87902e5c03355335fc5992a74ba0247a70d937f326d852fc613b7f53516c0963"}, + {file = "numpy-1.18.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9ab21d1cb156a620d3999dd92f7d1c86824c622873841d6b080ca5495fa10fef"}, + {file = "numpy-1.18.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:cdb3a70285e8220875e4d2bc394e49b4988bdb1298ffa4e0bd81b2f613be397c"}, + {file = "numpy-1.18.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:6d205249a0293e62bbb3898c4c2e1ff8a22f98375a34775a259a0523111a8f6c"}, + {file = "numpy-1.18.2-cp36-cp36m-win32.whl", hash = "sha256:a35af656a7ba1d3decdd4fae5322b87277de8ac98b7d9da657d9e212ece76a61"}, + {file = "numpy-1.18.2-cp36-cp36m-win_amd64.whl", hash = "sha256:1598a6de323508cfeed6b7cd6c4efb43324f4692e20d1f76e1feec7f59013448"}, + {file = "numpy-1.18.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:deb529c40c3f1e38d53d5ae6cd077c21f1d49e13afc7936f7f868455e16b64a0"}, + {file = "numpy-1.18.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:cd77d58fb2acf57c1d1ee2835567cd70e6f1835e32090538f17f8a3a99e5e34b"}, + {file = "numpy-1.18.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:b1fe1a6f3a6f355f6c29789b5927f8bd4f134a4bd9a781099a7c4f66af8850f5"}, + {file = "numpy-1.18.2-cp37-cp37m-win32.whl", hash = "sha256:2e40be731ad618cb4974d5ba60d373cdf4f1b8dcbf1dcf4d9dff5e212baf69c5"}, + {file = "numpy-1.18.2-cp37-cp37m-win_amd64.whl", hash = "sha256:4ba59db1fcc27ea31368af524dcf874d9277f21fd2e1f7f1e2e0c75ee61419ed"}, + {file = "numpy-1.18.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:59ca9c6592da581a03d42cc4e270732552243dc45e87248aa8d636d53812f6a5"}, + {file = "numpy-1.18.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1b0ece94018ae21163d1f651b527156e1f03943b986188dd81bc7e066eae9d1c"}, + {file = "numpy-1.18.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:82847f2765835c8e5308f136bc34018d09b49037ec23ecc42b246424c767056b"}, + {file = "numpy-1.18.2-cp38-cp38-win32.whl", hash = "sha256:5e0feb76849ca3e83dd396254e47c7dba65b3fa9ed3df67c2556293ae3e16de3"}, + {file = "numpy-1.18.2-cp38-cp38-win_amd64.whl", hash = "sha256:ba3c7a2814ec8a176bb71f91478293d633c08582119e713a0c5351c0f77698da"}, + {file = "numpy-1.18.2.zip", hash = "sha256:e7894793e6e8540dbeac77c87b489e331947813511108ae097f1715c018b8f3d"}, ] packaging = [ {file = "packaging-19.0-py2.py3-none-any.whl", hash = "sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3"}, {file = "packaging-19.0.tar.gz", hash = "sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af"}, ] pandas = [ - {file = "pandas-0.25.3-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:df8864824b1fe488cf778c3650ee59c3a0d8f42e53707de167ba6b4f7d35f133"}, - {file = "pandas-0.25.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:7458c48e3d15b8aaa7d575be60e1e4dd70348efcd9376656b72fecd55c59a4c3"}, - {file = "pandas-0.25.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:61741f5aeb252f39c3031d11405305b6d10ce663c53bc3112705d7ad66c013d0"}, - {file = "pandas-0.25.3-cp35-cp35m-win32.whl", hash = "sha256:adc3d3a3f9e59a38d923e90e20c4922fc62d1e5a03d083440468c6d8f3f1ae0a"}, - {file = "pandas-0.25.3-cp35-cp35m-win_amd64.whl", hash = "sha256:975c461accd14e89d71772e89108a050fa824c0b87a67d34cedf245f6681fc17"}, - {file = "pandas-0.25.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ee50c2142cdcf41995655d499a157d0a812fce55c97d9aad13bc1eef837ed36c"}, - {file = "pandas-0.25.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:4545467a637e0e1393f7d05d61dace89689ad6d6f66f267f86fff737b702cce9"}, - {file = "pandas-0.25.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:bbe3eb765a0b1e578833d243e2814b60c825b7fdbf4cdfe8e8aae8a08ed56ecf"}, - {file = "pandas-0.25.3-cp36-cp36m-win32.whl", hash = "sha256:8153705d6545fd9eb6dd2bc79301bff08825d2e2f716d5dced48daafc2d0b81f"}, - {file = "pandas-0.25.3-cp36-cp36m-win_amd64.whl", hash = "sha256:26382aab9c119735908d94d2c5c08020a4a0a82969b7e5eefb92f902b3b30ad7"}, - {file = "pandas-0.25.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:00dff3a8e337f5ed7ad295d98a31821d3d0fe7792da82d78d7fd79b89c03ea9d"}, - {file = "pandas-0.25.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e45055c30a608076e31a9fcd780a956ed3b1fa20db61561b8d88b79259f526f7"}, - {file = "pandas-0.25.3-cp37-cp37m-win32.whl", hash = "sha256:255920e63850dc512ce356233081098554d641ba99c3767dde9e9f35630f994b"}, - {file = "pandas-0.25.3-cp37-cp37m-win_amd64.whl", hash = "sha256:22361b1597c8c2ffd697aa9bf85423afa9e1fcfa6b1ea821054a244d5f24d75e"}, - {file = "pandas-0.25.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9962957a27bfb70ab64103d0a7b42fa59c642fb4ed4cb75d0227b7bb9228535d"}, - {file = "pandas-0.25.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:78bf638993219311377ce9836b3dc05f627a666d0dbc8cec37c0ff3c9ada673b"}, - {file = "pandas-0.25.3-cp38-cp38-win32.whl", hash = "sha256:6a3ac2c87e4e32a969921d1428525f09462770c349147aa8e9ab95f88c71ec71"}, - {file = "pandas-0.25.3-cp38-cp38-win_amd64.whl", hash = "sha256:33970f4cacdd9a0ddb8f21e151bfb9f178afb7c36eb7c25b9094c02876f385c2"}, - {file = "pandas-0.25.3.tar.gz", hash = "sha256:52da74df8a9c9a103af0a72c9d5fdc8e0183a90884278db7f386b5692a2220a4"}, + {file = "pandas-1.0.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d234bcf669e8b4d6cbcd99e3ce7a8918414520aeb113e2a81aeb02d0a533d7f7"}, + {file = "pandas-1.0.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:ca84a44cf727f211752e91eab2d1c6c1ab0f0540d5636a8382a3af428542826e"}, + {file = "pandas-1.0.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:1fa4bae1a6784aa550a1c9e168422798104a85bf9c77a1063ea77ee6f8452e3a"}, + {file = "pandas-1.0.3-cp36-cp36m-win32.whl", hash = "sha256:863c3e4b7ae550749a0bb77fa22e601a36df9d2905afef34a6965bed092ba9e5"}, + {file = "pandas-1.0.3-cp36-cp36m-win_amd64.whl", hash = "sha256:a210c91a02ec5ff05617a298ad6f137b9f6f5771bf31f2d6b6367d7f71486639"}, + {file = "pandas-1.0.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:11c7cb654cd3a0e9c54d81761b5920cdc86b373510d829461d8f2ed6d5905266"}, + {file = "pandas-1.0.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6597df07ea361231e60c00692d8a8099b519ed741c04e65821e632bc9ccb924c"}, + {file = "pandas-1.0.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:743bba36e99d4440403beb45a6f4f3a667c090c00394c176092b0b910666189b"}, + {file = "pandas-1.0.3-cp37-cp37m-win32.whl", hash = "sha256:07c1b58936b80eafdfe694ce964ac21567b80a48d972879a359b3ebb2ea76835"}, + {file = "pandas-1.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:12f492dd840e9db1688126216706aa2d1fcd3f4df68a195f9479272d50054645"}, + {file = "pandas-1.0.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0ebe327fb088df4d06145227a4aa0998e4f80a9e6aed4b61c1f303bdfdf7c722"}, + {file = "pandas-1.0.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:858a0d890d957ae62338624e4aeaf1de436dba2c2c0772570a686eaca8b4fc85"}, + {file = "pandas-1.0.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:387dc7b3c0424327fe3218f81e05fc27832772a5dffbed385013161be58df90b"}, + {file = "pandas-1.0.3-cp38-cp38-win32.whl", hash = "sha256:167a1315367cea6ec6a5e11e791d9604f8e03f95b57ad227409de35cf850c9c5"}, + {file = "pandas-1.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:1a7c56f1df8d5ad8571fa251b864231f26b47b59cbe41aa5c0983d17dbb7a8e4"}, + {file = "pandas-1.0.3.tar.gz", hash = "sha256:32f42e322fb903d0e189a4c10b75ba70d90958cc4f66a1781ed027f1a1d14586"}, ] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, diff --git a/pyproject.toml b/pyproject.toml index 48cf3c6d..a8c2c800 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,10 +27,11 @@ packages = [ include = ["tamr_client/py.typed"] [tool.poetry.dependencies] -python = "^3.6" +python = "^3.6.1" requests = "^2.22" simplejson = "^3.16" dataclasses = "^0.6.0" +pandas = "^1.0.3" [tool.poetry.dev-dependencies] Sphinx = "^2.1" @@ -42,7 +43,6 @@ toml = "^0.10.0" sphinx_rtd_theme = "^0.4.3" recommonmark = "^0.6.0" sphinx-autodoc-typehints = "^1.8" -pandas = "^0.25.3" pytest = "^5.3.2" invoke = "^1.4.0" mypy = "^0.770" From 542e76694f904648931d992fa4d104dda1e47bb9 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Sun, 5 Apr 2020 16:34:01 -0400 Subject: [PATCH 339/632] fix(docs): Args section formatting Args section needs to be preceded by empty newline for napoleon/sphinx to format docs correctly. --- tamr_client/datasets/dataframe.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tamr_client/datasets/dataframe.py b/tamr_client/datasets/dataframe.py index 112082ad..2a7f907e 100644 --- a/tamr_client/datasets/dataframe.py +++ b/tamr_client/datasets/dataframe.py @@ -25,6 +25,7 @@ def upsert( primary_key_name: Optional[str] = None, ) -> JsonDict: """Upserts a record for each row of `df` with attributes for each column in `df`. + Args: dataset: Dataset to receive record updates df: The DataFrame containing records to be upserted From 3553800f17077069ac680db61a62b111d454f5f0 Mon Sep 17 00:00:00 2001 From: Timo Roest Date: Mon, 6 Apr 2020 11:47:32 +0100 Subject: [PATCH 340/632] Return the result of the upsert command. --- tamr_unify_client/dataset/resource.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index 77d0525c..a5183ed8 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -268,7 +268,7 @@ def from_geo_features(self, features, geo_attr=None): if geo_attr is None: geo_attr = self._geo_attr - self._update_records( + return self._update_records( self._features_to_updates(features, record_id, key_attrs, geo_attr) ) From ab0f9323780ca9594220c714d21c54f6a6f9aa9a Mon Sep 17 00:00:00 2001 From: Timo Roest Date: Mon, 6 Apr 2020 12:01:57 +0100 Subject: [PATCH 341/632] Updated docstring --- tamr_unify_client/dataset/resource.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tamr_unify_client/dataset/resource.py b/tamr_unify_client/dataset/resource.py index a5183ed8..59340028 100644 --- a/tamr_unify_client/dataset/resource.py +++ b/tamr_unify_client/dataset/resource.py @@ -253,6 +253,8 @@ def from_geo_features(self, features, geo_attr=None): :param features: geospatial features :param geo_attr: (optional) name of the Tamr attribute to use for the feature's geometry :type geo_attr: str + :returns: JSON response body from server. + :rtype: :py:class:`dict` """ if hasattr(features, "__geo_interface__"): features = features.__geo_interface__ From 7092037dfee8ff2bdc4d037e7dd9aea8286dd4bb Mon Sep 17 00:00:00 2001 From: Timo Roest Date: Mon, 6 Apr 2020 14:34:59 +0100 Subject: [PATCH 342/632] Updated Changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb2f1755..4376da2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## 0.12.0-dev + **BUG FIXES** + - `from_geo_features` now returns information on the operation. + ## 0.11.0 **BETA** From e4312d7e40fb17eb59b6b87e4803e3bc2364c661 Mon Sep 17 00:00:00 2001 From: abafzal Date: Mon, 6 Apr 2020 12:38:56 -0400 Subject: [PATCH 343/632] added comma --- docs/user-guide/spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-guide/spec.md b/docs/user-guide/spec.md index dfc03bc6..96482787 100644 --- a/docs/user-guide/spec.md +++ b/docs/user-guide/spec.md @@ -6,7 +6,7 @@ Resources, such as projects, dataset, and attribute configurations, can be creat spec = { "name": "project", "description": "Mastering Project", - "type": "DEDUP" + "type": "DEDUP", "unifiedDatasetName": "project_unified_dataset" } project = tamr.projects.create(spec) From dc4c852ac28962c5bda4c8658169dacd0d505465 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 8 Apr 2020 16:20:39 -0400 Subject: [PATCH 344/632] Change tuc.Client.origin to return correct path if attribute port is None. --- tamr_unify_client/client.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tamr_unify_client/client.py b/tamr_unify_client/client.py index e788939d..c0cf7e64 100644 --- a/tamr_unify_client/client.py +++ b/tamr_unify_client/client.py @@ -43,7 +43,7 @@ def __init__( auth: requests.auth.AuthBase, host: str = "localhost", protocol: str = "http", - port: int = 9100, + port: Optional[int] = 9100, base_path: str = "/api/versioned/v1/", session: Optional[requests.Session] = None, ): @@ -70,7 +70,10 @@ def origin(self) -> str: For additional information, see `MDN web docs `_ . """ - return f"{self.protocol}://{self.host}:{self.port}" + if self.port is None: + return f"{self.protocol}://{self.host}" + else: + return f"{self.protocol}://{self.host}:{self.port}" def request(self, method: str, endpoint: str, **kwargs) -> requests.Response: """Sends a request to Tamr. From acbce0a1966ead204cee6b8637c5015e547be814 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 8 Apr 2020 16:25:56 -0400 Subject: [PATCH 345/632] Change tc.instance.origin to return correct path if attribute port is None. --- tamr_client/instance.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tamr_client/instance.py b/tamr_client/instance.py index e9bf9f6b..75e782d0 100644 --- a/tamr_client/instance.py +++ b/tamr_client/instance.py @@ -1,11 +1,12 @@ from dataclasses import dataclass +from typing import Optional @dataclass(frozen=True) class Instance: protocol: str = "http" host: str = "localhost" - port: int = 9100 + port: Optional[int] = 9100 def origin(instance: Instance) -> str: @@ -13,4 +14,7 @@ def origin(instance: Instance) -> str: For additional information, see `MDN web docs `_ . """ - return f"{instance.protocol}://{instance.host}:{instance.port}" + if instance.port is None: + return f"{instance.protocol}://{instance.host}" + else: + return f"{instance.protocol}://{instance.host}:{instance.port}" From 0bf5251a9233de1abfed14d64464b0f8aea8f643 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 8 Apr 2020 17:25:26 -0400 Subject: [PATCH 346/632] Change tc.instance default port to None. --- tamr_client/instance.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_client/instance.py b/tamr_client/instance.py index 75e782d0..29b974b5 100644 --- a/tamr_client/instance.py +++ b/tamr_client/instance.py @@ -6,7 +6,7 @@ class Instance: protocol: str = "http" host: str = "localhost" - port: Optional[int] = 9100 + port: Optional[int] = None def origin(instance: Instance) -> str: From ebcb9cc2ffa3cf57a506b5e94e9667491f06155c Mon Sep 17 00:00:00 2001 From: Timo Roest Date: Thu, 9 Apr 2020 09:36:56 +0100 Subject: [PATCH 347/632] Updated documentation to document issues with GeoPandas. --- docs/user-guide/geo.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/user-guide/geo.md b/docs/user-guide/geo.md index dc74a730..cad0f6bf 100644 --- a/docs/user-guide/geo.md +++ b/docs/user-guide/geo.md @@ -78,3 +78,29 @@ from geopandas import GeoDataFrame df = GeoDataFrame.from_features(my_dataset.itergeofeatures()) ``` This allows construction of a GeoDataFrame directly from the stream of records, without materializing the intermediate dataset. + +## Note on GeoPandas data access +There is a current limitation in [GeoPandas](https://github.com/geopandas/geopandas/issues/1208) that causes the feature's ID field to be ignored in certain scenarios. The Tamr primary key is stored in this field. +The result is that when loading data and updating records through the `dataset.from_geo_features()` method, records will not be overwritten as anticipated. + +This issue can be circumvented by loading features into GeoPandas by re-inserting the id field into the data. + +```python +my_dataset = client.datasets.by_name("my_dataset") +for feature in my_dataset.itergeofeatures(): + primary_key = feature['id'] + df = gpd.GeoDataFrame.from_features([feature]) + do_something(df) + geo.index = [primary_key] + my_dataset.from_geo_features(df) +``` + +Alternatively, it is possible to load the full dataset as follows: +```python +def geopandas_dataset(dataset): + for feature in dataset.itergeofeatures(): + feature['properties']['primary_key'] = feature['id'] + yield feature +df = gpd.GeoDataFrame.from_features(geo_dataset(test_dataset)) +df.set_index('primary_key') +``` \ No newline at end of file From 828258d327cfa7cee8102ce62faa72bf8849c754 Mon Sep 17 00:00:00 2001 From: Timo Roest Date: Thu, 9 Apr 2020 09:51:00 +0100 Subject: [PATCH 348/632] Updated documentation to document issues with GeoPandas. --- docs/user-guide/geo.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/user-guide/geo.md b/docs/user-guide/geo.md index cad0f6bf..3808736d 100644 --- a/docs/user-guide/geo.md +++ b/docs/user-guide/geo.md @@ -38,6 +38,7 @@ geodataframe = geopandas.GeoDataFrame(...) dataset = client.dataset.by_name("my_dataset") dataset.from_geo_features(geodataframe) ``` +Note that there are currently some limitations to GeoPandas' implementation of the Geo Interface. See below for more details. By default the features' geometries will be placed into the first dataset attribute with geometry type. You can override this by specifying the geometry attribute to use in the `geo_attr` @@ -97,10 +98,13 @@ for feature in my_dataset.itergeofeatures(): Alternatively, it is possible to load the full dataset as follows: ```python +my_dataset = client.datasets.by_name("my_dataset") def geopandas_dataset(dataset): for feature in dataset.itergeofeatures(): feature['properties']['primary_key'] = feature['id'] yield feature -df = gpd.GeoDataFrame.from_features(geo_dataset(test_dataset)) +df = gpd.GeoDataFrame.from_features(geo_dataset(my_dataset)) df.set_index('primary_key') -``` \ No newline at end of file +do_something(df) +my_dataset.from_geo_features(df) +``` From 5f7c6e276af3a8e25d988d1c5d752ceee7fbd8a1 Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 10 Apr 2020 16:05:27 -0400 Subject: [PATCH 349/632] Update CHANGELOG. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4376da2f..c739f1f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ **BUG FIXES** - `from_geo_features` now returns information on the operation. + - [#366](https://github.com/Datatamer/tamr-client/issues/366) Now able to connect to Tamr instance with implicit port ## 0.11.0 **BETA** From 276899ddb6461abae83b84ac8ffe51e301e377bf Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 10 Apr 2020 16:05:41 -0400 Subject: [PATCH 350/632] Add tuc tests. --- tests/unit/test_client_origin.py | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tests/unit/test_client_origin.py diff --git a/tests/unit/test_client_origin.py b/tests/unit/test_client_origin.py new file mode 100644 index 00000000..7e0c8667 --- /dev/null +++ b/tests/unit/test_client_origin.py @@ -0,0 +1,37 @@ +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth + + +def test_client_default(): + auth = UsernamePasswordAuth("username", "password") + client = Client(auth) + + assert client.origin == "http://localhost:9100" + + +def test_client_set_protocol(): + auth = UsernamePasswordAuth("username", "password") + client = Client(auth, protocol="https") + + assert client.origin == "https://localhost:9100" + + +def test_client_set_host(): + auth = UsernamePasswordAuth("username", "password") + client = Client(auth, host="123.123.123.123") + + assert client.origin == "http://123.123.123.123:9100" + + +def test_client_set_port(): + auth = UsernamePasswordAuth("username", "password") + client = Client(auth, port=80) + + assert client.origin == "http://localhost:80" + + +def test_client_set_port_none(): + auth = UsernamePasswordAuth("username", "password") + client = Client(auth, port=None) + + assert client.origin == "http://localhost" From 7e2dc4def893a8f51456e8bea4b5604daaf302fc Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 10 Apr 2020 16:06:06 -0400 Subject: [PATCH 351/632] Add example to tuc.Client. --- tamr_unify_client/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tamr_unify_client/client.py b/tamr_unify_client/client.py index c0cf7e64..5f674ddd 100644 --- a/tamr_unify_client/client.py +++ b/tamr_unify_client/client.py @@ -36,6 +36,7 @@ class Client: >>> auth = UsernamePasswordAuth('my username', 'my password') >>> tamr_local = Client(auth) # on http://localhost:9100 >>> tamr_remote = Client(auth, protocol='https', host='10.0.10.0') # on https://10.0.10.0:9100 + >>> tamr_remote = Client(auth, protocol='https', host='10.0.10.0', port=None) # on https://10.0.10.0 """ def __init__( From 00520ffb72bce4d7d33bc4ae29c0b45ac012149c Mon Sep 17 00:00:00 2001 From: skalish Date: Fri, 10 Apr 2020 16:11:11 -0400 Subject: [PATCH 352/632] Add tc tests. --- tests/tamr_client/test_instance.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/tamr_client/test_instance.py diff --git a/tests/tamr_client/test_instance.py b/tests/tamr_client/test_instance.py new file mode 100644 index 00000000..a60c9b2c --- /dev/null +++ b/tests/tamr_client/test_instance.py @@ -0,0 +1,21 @@ +import tamr_client as tc + + +def test_instance_default(): + instance = tc.Instance() + assert tc.instance.origin(instance) == "http://localhost" + + +def test_client_set_protocol(): + instance = tc.Instance(protocol="https") + assert tc.instance.origin(instance) == "https://localhost" + + +def test_client_set_host(): + instance = tc.Instance(host="123.123.123.123") + assert tc.instance.origin(instance) == "http://123.123.123.123" + + +def test_client_set_port(): + instance = tc.Instance(port=9100) + assert tc.instance.origin(instance) == "http://localhost:9100" From 693674c7cc63550e88b045e4505bebad35eef298 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 8 Apr 2020 17:07:41 -0400 Subject: [PATCH 353/632] tc.project and tc.mastering.project --- tamr_client/__init__.py | 3 + tamr_client/mastering/__init__.py | 8 +++ tamr_client/mastering/project.py | 34 ++++++++++ tamr_client/project.py | 65 +++++++++++++++++++ tests/tamr_client/data/mastering_project.json | 19 ++++++ tests/tamr_client/test_project.py | 32 +++++++++ 6 files changed, 161 insertions(+) create mode 100644 tamr_client/mastering/__init__.py create mode 100644 tamr_client/mastering/project.py create mode 100644 tamr_client/project.py create mode 100644 tests/tamr_client/data/mastering_project.json create mode 100644 tests/tamr_client/test_project.py diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index a2afe254..83c0bca9 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -64,3 +64,6 @@ # dataframe from tamr_client.datasets.dataframe import AmbiguousPrimaryKey import tamr_client.datasets.dataframe as dataframe + +import tamr_client.mastering as mastering +import tamr_client.project as project diff --git a/tamr_client/mastering/__init__.py b/tamr_client/mastering/__init__.py new file mode 100644 index 00000000..252ac0ca --- /dev/null +++ b/tamr_client/mastering/__init__.py @@ -0,0 +1,8 @@ +# flake8: noqa +""" +Tamr - Mastering +See https://docs.tamr.com/docs/overall-workflow-mastering +""" + +from tamr_client.mastering.project import Project +import tamr_client.mastering.project diff --git a/tamr_client/mastering/project.py b/tamr_client/mastering/project.py new file mode 100644 index 00000000..04001083 --- /dev/null +++ b/tamr_client/mastering/project.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass +from typing import Optional + +import tamr_client as tc +from tamr_client.types import JsonDict + + +@dataclass(frozen=True) +class Project: + """A Tamr Mastering project + + See https://docs.tamr.com/reference/the-project-object + + Args: + url + name + description + """ + + url: tc.URL + name: str + description: Optional[str] = None + + +def _from_json(url: tc.URL, data: JsonDict) -> Project: + """Make mastering project from JSON data (deserialize) + + Args: + url: Project URL + data: Project JSON data from Tamr server + """ + return tc.mastering.Project( + url, name=data["name"], description=data.get("description") + ) diff --git a/tamr_client/project.py b/tamr_client/project.py new file mode 100644 index 00000000..7ccf5466 --- /dev/null +++ b/tamr_client/project.py @@ -0,0 +1,65 @@ +from typing import Union + +import tamr_client as tc +from tamr_client.types import JsonDict + +Project = Union[tc.mastering.Project] + + +class NotFound(Exception): + """Raised when referencing (e.g. updating or deleting) a project + that does not exist on the server.""" + + pass + + +def from_resource_id(session: tc.Session, instance: tc.Instance, id: str) -> Project: + """Get project by resource ID + + Fetches project from Tamr server + + Args: + instance: Tamr instance containing this dataset + id: Project ID + + Raises: + NotFound: If no project could be found at the specified URL. + Corresponds to a 404 HTTP error. + requests.HTTPError: If any other HTTP error is encountered. + """ + url = tc.URL(instance=instance, path=f"projects/{id}") + return _from_url(session, url) + + +def _from_url(session: tc.Session, url: tc.URL) -> Project: + """Get project by URL + + Fetches project from Tamr server + + Args: + url: Project URL + + Raises: + NotFound: If no project could be found at the specified URL. + Corresponds to a 404 HTTP error. + requests.HTTPError: If any other HTTP error is encountered. + """ + r = session.get(str(url)) + if r.status_code == 404: + raise NotFound(str(url)) + data = tc.response.successful(r).json() + return _from_json(url, data) + + +def _from_json(url: tc.URL, data: JsonDict) -> Project: + """Make project from JSON data (deserialize) + + Args: + url: Project URL + data: Project JSON data from Tamr server + """ + proj_type = data["type"] + if proj_type == "DEDUP": + return tc.mastering.project._from_json(url, data) + else: + raise ValueError(f"Unrecognized project type '{proj_type}' in {repr(data)}") diff --git a/tests/tamr_client/data/mastering_project.json b/tests/tamr_client/data/mastering_project.json new file mode 100644 index 00000000..1563eec0 --- /dev/null +++ b/tests/tamr_client/data/mastering_project.json @@ -0,0 +1,19 @@ +{ + "id": "unify://unified-data/v1/projects/1", + "name": "proj", + "description": "Mastering Project", + "type": "DEDUP", + "unifiedDatasetName": "proj_unified_dataset", + "created": { + "username": "admin", + "time": "2020-04-03T14:14:18.752Z", + "version": "18" + }, + "lastModified": { + "username": "admin", + "time": "2020-04-03T14:14:20.115Z", + "version": "19" + }, + "relativeId": "projects/1", + "externalId": "58bdbe72-3c08-427d-97bd-45b16d92c79c" +} diff --git a/tests/tamr_client/test_project.py b/tests/tamr_client/test_project.py new file mode 100644 index 00000000..10f96126 --- /dev/null +++ b/tests/tamr_client/test_project.py @@ -0,0 +1,32 @@ +import pytest +import responses + +import tamr_client as tc +import tests.tamr_client.utils as utils + + +@responses.activate +def test_from_resource_id_mastering(): + s = utils.session() + instance = utils.instance() + + project_json = utils.load_json("mastering_project.json") + url = tc.URL(path="projects/1") + responses.add(responses.GET, str(url), json=project_json) + + project = tc.project.from_resource_id(s, instance, "1") + assert isinstance(project, tc.mastering.Project) + assert project.name == "proj" + assert project.description == "Mastering Project" + + +@responses.activate +def test_from_resource_id_not_found(): + s = utils.session() + instance = utils.instance() + + url = tc.URL(path="projects/1") + responses.add(responses.GET, str(url), status=404) + + with pytest.raises(tc.project.NotFound): + tc.project.from_resource_id(s, instance, "1") From f56a71499b179a94f7fcac7da6b28bbf64fb8ae6 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 9 Apr 2020 11:57:10 -0400 Subject: [PATCH 354/632] Project docs --- docs/beta.md | 4 +++- docs/beta/mastering.md | 3 +++ docs/beta/mastering/project.rst | 4 ++++ docs/beta/project.rst | 10 ++++++++++ 4 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 docs/beta/mastering.md create mode 100644 docs/beta/mastering/project.rst create mode 100644 docs/beta/project.rst diff --git a/docs/beta.md b/docs/beta.md index 59a31b02..2aa8177b 100644 --- a/docs/beta.md +++ b/docs/beta.md @@ -9,5 +9,7 @@ * [Auth](beta/auth) * [Dataset](beta/datasets) * [Instance](beta/instance) + * [Mastering](beta/mastering) + * [Project](beta/project) * [Response](beta/response) - * [Session](beta/session) \ No newline at end of file + * [Session](beta/session) diff --git a/docs/beta/mastering.md b/docs/beta/mastering.md new file mode 100644 index 00000000..f10e8d68 --- /dev/null +++ b/docs/beta/mastering.md @@ -0,0 +1,3 @@ +# Mastering + + * [Project](/beta/mastering/project) diff --git a/docs/beta/mastering/project.rst b/docs/beta/mastering/project.rst new file mode 100644 index 00000000..15928d0a --- /dev/null +++ b/docs/beta/mastering/project.rst @@ -0,0 +1,4 @@ +Mastering Project +================= + +.. autoclass:: tamr_client.mastering.Project diff --git a/docs/beta/project.rst b/docs/beta/project.rst new file mode 100644 index 00000000..c2e3b860 --- /dev/null +++ b/docs/beta/project.rst @@ -0,0 +1,10 @@ +Project +======= + +.. autofunction:: tamr_client.project.from_resource_id + +Exceptions +---------- + +.. autoclass:: tamr_client.project.NotFound + :no-inherited-members: From 368aaa1f777f73afb1bf66befc2de2885253a2bd Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 9 Apr 2020 12:54:24 -0400 Subject: [PATCH 355/632] changelog entry for project support --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c739f1f1..e834467d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,11 @@ ## 0.12.0-dev + **BETA** + + Important: Do not use BETA features for production workflows. + + - [#367](https://github.com/Datatamer/tamr-client/issues/367) Support for projects: + - generic projects via `tc.project` + - Mastering projects via `tc.mastering.project` **BUG FIXES** - `from_geo_features` now returns information on the operation. From 4f371de7fdb9b8a67334440145318d9a995fc55c Mon Sep 17 00:00:00 2001 From: Timo Date: Tue, 14 Apr 2020 08:37:04 +0100 Subject: [PATCH 356/632] Added Geospatial documentation to CHANGELOG Added Geospatial documentation to CHANGELOG --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4376da2f..c7c22ae3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ **BUG FIXES** - `from_geo_features` now returns information on the operation. + + **NEW FEATURES** + - Added user documentation on [Geospatial functionalities with GeoPandas](https://github.com/Datatamer/tamr-client/blob/master/docs/user-guide/geo.md). Documented limitations in Geopandas and workarounds. ## 0.11.0 **BETA** From 598f3eea2e7ab09f74ecf794a6c9875ebe6c6ff9 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Tue, 28 Apr 2020 14:52:16 -0400 Subject: [PATCH 357/632] update copyright year to 2020 --- LICENSE | 2 +- docs/conf.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index a4f8764a..bc5bfdd8 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2019 Tamr + Copyright 2020 Tamr Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/docs/conf.py b/docs/conf.py index a5dc744b..86a50dbe 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,7 +24,7 @@ # -- Project information ----------------------------------------------------- project = "Tamr - Python Client" -copyright = "2019, Tamr" +copyright = "2020, Tamr" author = "Tamr" From c9800109b5f2bf1f65456957d4bdf8982d266c42 Mon Sep 17 00:00:00 2001 From: Timo Roest Date: Tue, 28 Apr 2020 13:07:06 +0100 Subject: [PATCH 358/632] Connecting + custom generators. --- docs/user-guide/pandas.md | 84 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 docs/user-guide/pandas.md diff --git a/docs/user-guide/pandas.md b/docs/user-guide/pandas.md new file mode 100644 index 00000000..26f48ac6 --- /dev/null +++ b/docs/user-guide/pandas.md @@ -0,0 +1,84 @@ +# Pandas Workflow + +## Connecting To Tamr + +Connecting to a Tamr instance: + +```python +import os +import pandas as pd +from tamr_unify_client import Client +from tamr_unify_client.auth import UsernamePasswordAuth + +username = os.environ['TAMR_USERNAME'] +password = os.environ['TAMR_PASSWORD'] + +auth = UsernamePasswordAuth(username, password) +tamr = Client(auth) +``` +## How to load a dataset as a pandas Dataframe + +### In Memory + +Loading a `dataset` as a pandas `dataframe` is possible via the `from_records()` method that pandas provides. +An example is shown below: + +```python +my_dataset = tamr.datasets.by_name("my_tamr_dataset") +df = pd.DataFrame.from_records(my_dataset.records()) +``` + +This will construct a pandas dataframe based on the records that are streamed in, and stored in the pandas dataframe. +Once all records have been loaded, you will be able to interact with the dataframe normally. + +Note that as values are typically represented inside `arrays` within Tamr, the values will be encapsulated `lists` +inside the dataframe. You can use traditional methods in pandas to deal with this; for example `.explode()`. + +### Streaming + + +#### Custom Generators +In order to customise the data loaded into the pandas dataframe, you can customise the generator object (`dataset.records()`) +that is read into pandas. + +For example, it is possible to automatically flatten all lists with a length of one, and apply this to the `dataset.records()` +generator as follows: + +```python +def unlist(lst): + """ + If object is a list of length one, return first element. + Otherwise, return original object. + """ + if isinstance(lst, list) and len(lst) is 1: + return lst[0] + else: + return lst + +def dataset_to_pandas(dataset): + """ + Incorporates basic unlisting for easy transfer between Tamr and Pandas. + """ + for record in dataset.records(): + for key in record: + record[key] = unlist(record[key]) + yield record + +df = pd.DataFrame.from_records(dataset_to_pandas(my_dataset)) +``` + +Similarly, if you only require certain attributes to be loaded, you could customise as follows: + +```python +def filter_dataset_to_pandas(dataset, colnames): + """ + Filter the dataset to only the columns specified as a list in colnames. Note: To upsert the records you need your + dataset's primary key. This snippet will always load the primary key if it wasn't provided. + """ + assert isinstance(colnames, list) + colnames = dataset.key_attribute_names + colnames if dataset.key_attribute_names[0] not in colnames else colnames + for record in dataset.records(): + yield {k: unlist(v) for k, v in record.items() if k in colnames} + +df = pd.DataFrame.from_records(filter_dataset_to_pandas(dataset, ['City', 'new_attr'])) +``` \ No newline at end of file From b869959faa85e77054f853926ec8e7967fd14066 Mon Sep 17 00:00:00 2001 From: Timo Roest Date: Tue, 28 Apr 2020 14:42:16 +0100 Subject: [PATCH 359/632] Add additional examples and snippets. --- docs/user-guide/pandas.md | 74 ++++++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 9 deletions(-) diff --git a/docs/user-guide/pandas.md b/docs/user-guide/pandas.md index 26f48ac6..6f7f430c 100644 --- a/docs/user-guide/pandas.md +++ b/docs/user-guide/pandas.md @@ -16,7 +16,7 @@ password = os.environ['TAMR_PASSWORD'] auth = UsernamePasswordAuth(username, password) tamr = Client(auth) ``` -## How to load a dataset as a pandas Dataframe +## Load dataset as Dataframe ### In Memory @@ -32,14 +32,15 @@ This will construct a pandas dataframe based on the records that are streamed in Once all records have been loaded, you will be able to interact with the dataframe normally. Note that as values are typically represented inside `arrays` within Tamr, the values will be encapsulated `lists` -inside the dataframe. You can use traditional methods in pandas to deal with this; for example `.explode()`. +inside the dataframe. You can use traditional methods in pandas to deal with this; for example by calling `.explode()`, +or extracting specific elements. ### Streaming - +TODO #### Custom Generators -In order to customise the data loaded into the pandas dataframe, you can customise the generator object (`dataset.records()`) -that is read into pandas. +In order to customise the data loaded into the pandas dataframe, it is possible to customise the generator object +`dataset.records()` by wrapping it in a different generator. For example, it is possible to automatically flatten all lists with a length of one, and apply this to the `dataset.records()` generator as follows: @@ -67,18 +68,73 @@ def dataset_to_pandas(dataset): df = pd.DataFrame.from_records(dataset_to_pandas(my_dataset)) ``` -Similarly, if you only require certain attributes to be loaded, you could customise as follows: +Similarly, if to filter to certain attributes only, it is possible to modify as follows: ```python def filter_dataset_to_pandas(dataset, colnames): """ - Filter the dataset to only the columns specified as a list in colnames. Note: To upsert the records you need your - dataset's primary key. This snippet will always load the primary key if it wasn't provided. + Filter the dataset to only the primary key and the columns specified as a list in colnames. """ assert isinstance(colnames, list) colnames = dataset.key_attribute_names + colnames if dataset.key_attribute_names[0] not in colnames else colnames for record in dataset.records(): yield {k: unlist(v) for k, v in record.items() if k in colnames} -df = pd.DataFrame.from_records(filter_dataset_to_pandas(dataset, ['City', 'new_attr'])) +df = pd.DataFrame.from_records(filter_dataset_to_pandas(my_dataset, ['City', 'new_attr'])) +``` + +Note that upserting these records would overwite the existing records and attributes, and cause loss of the data +stored in the unloaded attributes. + +## Upload Dataframe as Dataset + +### Create New Dataset +To create a new dataset and upload data, the convenience function `dataset.create_from_dataframe()` can be used. +Note that Tamr will throw an error if columns aren't generally formatted as strings. (The exception being geospatial +columns. For that, see the geospatial guide.) + +In order to achieve this, the following code will transform the column types to string. +```python +df = df.astype(str) +``` + +Creating the dataset is as easy as calling: +```python +tamr.datasets.create_from_dataframe(df, 'primaryKey', 'my_new_dataset') +``` + +### Make Changes to Dataset +When making changes to a dataset that was loaded as a dataframe, changes can be pushed back to Tamr using the +`dataset.upsert_records()` method as follows: + +```python +df = pd.DataFrame.from_records(my_dataset.records()) +df['column'] = 'new_value' +my_dataset.upsert_from_dataframe(df, primary_key_name='primary_key') +``` + +### Add Attributes +When making changes to dataframes, new columns are not automatically created when upserting records to Tamr. +In order for these changes to be recorded, these attributes first need to be created. + +One way of creating these automatically would be as follows: + +```python +def add_missing_attributes(dataset, df): + """ + Detects any attributes in the dataframe that aren't in the dataset and attempts to add them (as strings). + """ + existing_attributes = [att.name for att in dataset.attributes] + new_attributes = [att for att in df.columns.to_list() if att not in existing_attributes] + + if not new_attributes: + return + + for new_attribute in new_attributes: + attr_spec = {"name": new_attribute, + "type": {"baseType": "ARRAY", "innerType": {"baseType": "STRING"}}, + } + dataset.attributes.create(attr_spec) + +add_missing_attributes(my_dataset, df) ``` \ No newline at end of file From 9f5fd1d882f36e5d0d4bae62a502563b23b58b66 Mon Sep 17 00:00:00 2001 From: Timo Roest Date: Wed, 29 Apr 2020 13:35:26 +0100 Subject: [PATCH 360/632] Streaming through dataset information added. --- docs/user-guide/pandas.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/user-guide/pandas.md b/docs/user-guide/pandas.md index 6f7f430c..433c1881 100644 --- a/docs/user-guide/pandas.md +++ b/docs/user-guide/pandas.md @@ -36,9 +36,16 @@ inside the dataframe. You can use traditional methods in pandas to deal with thi or extracting specific elements. ### Streaming -TODO +When working with large `datasets` it is sometimes better not to work in memory, but to iterate through a dataset. +Since `dataset.records()` is a generator, this can easily be done as follows: +```python +for record in dataset.records(): + df = pd.DataFrame.from_records(record) + df['column_to_change'] = 'new_value' + dataset.upsert_from_dataframe(df, primary_key_name='primary_key') +``` -#### Custom Generators +### Custom Generators In order to customise the data loaded into the pandas dataframe, it is possible to customise the generator object `dataset.records()` by wrapping it in a different generator. @@ -91,7 +98,7 @@ stored in the unloaded attributes. ### Create New Dataset To create a new dataset and upload data, the convenience function `dataset.create_from_dataframe()` can be used. Note that Tamr will throw an error if columns aren't generally formatted as strings. (The exception being geospatial -columns. For that, see the geospatial guide.) +columns. For that, see the geospatial examples.) In order to achieve this, the following code will transform the column types to string. ```python @@ -103,7 +110,7 @@ Creating the dataset is as easy as calling: tamr.datasets.create_from_dataframe(df, 'primaryKey', 'my_new_dataset') ``` -### Make Changes to Dataset +### Changing Values When making changes to a dataset that was loaded as a dataframe, changes can be pushed back to Tamr using the `dataset.upsert_records()` method as follows: @@ -113,11 +120,11 @@ df['column'] = 'new_value' my_dataset.upsert_from_dataframe(df, primary_key_name='primary_key') ``` -### Add Attributes -When making changes to dataframes, new columns are not automatically created when upserting records to Tamr. -In order for these changes to be recorded, these attributes first need to be created. +### Adding Attributes +When making changes to dataframes, new dataframe columns are not automatically created as attributes when upserting +records to Tamr. In order for these changes to be recorded, these attributes first need to be created. -One way of creating these automatically would be as follows: +One way of creating these for source datasets automatically would be as follows: ```python def add_missing_attributes(dataset, df): From dfa4ef25c70a53c8abab53bdc5781166ef97ffe3 Mon Sep 17 00:00:00 2001 From: Timo Roest Date: Wed, 29 Apr 2020 13:59:53 +0100 Subject: [PATCH 361/632] Updated CHANGELOG.md --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79eb37cc..ae4a7ba6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ **NEW FEATURES** - Added user documentation on [Geospatial functionalities with GeoPandas](https://github.com/Datatamer/tamr-client/blob/master/docs/user-guide/geo.md). Documented limitations in Geopandas and workarounds. - [#366](https://github.com/Datatamer/tamr-client/issues/366) Now able to connect to Tamr instance with implicit port - + - [#376](https://github.com/Datatamer/tamr-client/issues/376) Added user documentation on [Working with Pandas](https://github.com/Datatamer/tamr-client/blob/master/docs/user-guide/pandas.md). + ## 0.11.0 **BETA** From 7944b148a575a281e6bd6e23deb09686edda3ea5 Mon Sep 17 00:00:00 2001 From: Timo Roest Date: Wed, 29 Apr 2020 16:26:29 +0100 Subject: [PATCH 362/632] Incorporated feedback + added to index.md --- docs/index.md | 1 + docs/user-guide/pandas.md | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/index.md b/docs/index.md index 884e25f1..9a5aca15 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,6 +35,7 @@ assert op.succeeded() * [Create and update resources](user-guide/spec) * [Logging](user-guide/logging) * [Geospatial data](user-guide/geo) + * [Pandas usage](user-guide/pandas) * [Advanced usage](user-guide/advanced-usage) ## Contributor Guide diff --git a/docs/user-guide/pandas.md b/docs/user-guide/pandas.md index 433c1881..aa3a655a 100644 --- a/docs/user-guide/pandas.md +++ b/docs/user-guide/pandas.md @@ -41,8 +41,6 @@ Since `dataset.records()` is a generator, this can easily be done as follows: ```python for record in dataset.records(): df = pd.DataFrame.from_records(record) - df['column_to_change'] = 'new_value' - dataset.upsert_from_dataframe(df, primary_key_name='primary_key') ``` ### Custom Generators @@ -75,7 +73,7 @@ def dataset_to_pandas(dataset): df = pd.DataFrame.from_records(dataset_to_pandas(my_dataset)) ``` -Similarly, if to filter to certain attributes only, it is possible to modify as follows: +Similarly, it is possible to filter to extracting only certain attributes, by specifying this in the generator: ```python def filter_dataset_to_pandas(dataset, colnames): @@ -111,8 +109,10 @@ tamr.datasets.create_from_dataframe(df, 'primaryKey', 'my_new_dataset') ``` ### Changing Values + +#### In Memory When making changes to a dataset that was loaded as a dataframe, changes can be pushed back to Tamr using the -`dataset.upsert_records()` method as follows: +`dataset.upsert_from_dataframe()` method as follows: ```python df = pd.DataFrame.from_records(my_dataset.records()) @@ -120,6 +120,15 @@ df['column'] = 'new_value' my_dataset.upsert_from_dataframe(df, primary_key_name='primary_key') ``` +#### Streaming +For larger datasets it might be better to stream the data and apply changes while iterating through the dataset. +This way the full dataset does not need to be loaded into memory. +```python +for record in dataset.records(): + df = pd.DataFrame.from_records(record) + df['column_to_change'] = 'new_value' + dataset.upsert_from_dataframe(df, primary_key_name='primary_key') +``` ### Adding Attributes When making changes to dataframes, new dataframe columns are not automatically created as attributes when upserting records to Tamr. In order for these changes to be recorded, these attributes first need to be created. From d517dcda9b0123e47cf1d7eeab16114c4ec7462b Mon Sep 17 00:00:00 2001 From: Timo Roest Date: Wed, 29 Apr 2020 16:34:16 +0100 Subject: [PATCH 363/632] Changed headers --- docs/user-guide/pandas.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/user-guide/pandas.md b/docs/user-guide/pandas.md index aa3a655a..0290d376 100644 --- a/docs/user-guide/pandas.md +++ b/docs/user-guide/pandas.md @@ -18,7 +18,7 @@ tamr = Client(auth) ``` ## Load dataset as Dataframe -### In Memory +### Loading: In Memory Loading a `dataset` as a pandas `dataframe` is possible via the `from_records()` method that pandas provides. An example is shown below: @@ -35,7 +35,7 @@ Note that as values are typically represented inside `arrays` within Tamr, the v inside the dataframe. You can use traditional methods in pandas to deal with this; for example by calling `.explode()`, or extracting specific elements. -### Streaming +### Loading: Streaming When working with large `datasets` it is sometimes better not to work in memory, but to iterate through a dataset. Since `dataset.records()` is a generator, this can easily be done as follows: ```python @@ -110,7 +110,7 @@ tamr.datasets.create_from_dataframe(df, 'primaryKey', 'my_new_dataset') ### Changing Values -#### In Memory +#### Making Changes: In Memory When making changes to a dataset that was loaded as a dataframe, changes can be pushed back to Tamr using the `dataset.upsert_from_dataframe()` method as follows: @@ -120,7 +120,7 @@ df['column'] = 'new_value' my_dataset.upsert_from_dataframe(df, primary_key_name='primary_key') ``` -#### Streaming +#### Making Changes: Streaming For larger datasets it might be better to stream the data and apply changes while iterating through the dataset. This way the full dataset does not need to be loaded into memory. ```python From 0fc0e33617b6a89d75f7eb16ebc1a9ee1280b67c Mon Sep 17 00:00:00 2001 From: Timo Date: Wed, 29 Apr 2020 17:00:45 +0100 Subject: [PATCH 364/632] Update docs/user-guide/pandas.md Co-Authored-By: Dominick Olivito --- docs/user-guide/pandas.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user-guide/pandas.md b/docs/user-guide/pandas.md index 0290d376..e71c2e6d 100644 --- a/docs/user-guide/pandas.md +++ b/docs/user-guide/pandas.md @@ -88,7 +88,7 @@ def filter_dataset_to_pandas(dataset, colnames): df = pd.DataFrame.from_records(filter_dataset_to_pandas(my_dataset, ['City', 'new_attr'])) ``` -Note that upserting these records would overwite the existing records and attributes, and cause loss of the data +Note that upserting these records back to the original Tamr Dataset would overwite the existing records and attributes, and cause loss of the data stored in the unloaded attributes. ## Upload Dataframe as Dataset @@ -153,4 +153,4 @@ def add_missing_attributes(dataset, df): dataset.attributes.create(attr_spec) add_missing_attributes(my_dataset, df) -``` \ No newline at end of file +``` From 7f454a63c1bcf35a1d7756ebd4099e8f32485b08 Mon Sep 17 00:00:00 2001 From: Timo Date: Wed, 29 Apr 2020 17:00:56 +0100 Subject: [PATCH 365/632] Update docs/user-guide/pandas.md Co-Authored-By: Dominick Olivito --- docs/user-guide/pandas.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-guide/pandas.md b/docs/user-guide/pandas.md index e71c2e6d..1e143cb5 100644 --- a/docs/user-guide/pandas.md +++ b/docs/user-guide/pandas.md @@ -89,7 +89,7 @@ df = pd.DataFrame.from_records(filter_dataset_to_pandas(my_dataset, ['City', 'ne ``` Note that upserting these records back to the original Tamr Dataset would overwite the existing records and attributes, and cause loss of the data -stored in the unloaded attributes. +stored in the removed attributes. ## Upload Dataframe as Dataset From 865ac38e85bbf37078ddaafc545ea9ac41446d9e Mon Sep 17 00:00:00 2001 From: Timo Roest Date: Thu, 30 Apr 2020 09:57:23 +0100 Subject: [PATCH 366/632] Clarified intention --- docs/user-guide/pandas.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/user-guide/pandas.md b/docs/user-guide/pandas.md index 1e143cb5..bde21753 100644 --- a/docs/user-guide/pandas.md +++ b/docs/user-guide/pandas.md @@ -39,8 +39,10 @@ or extracting specific elements. When working with large `datasets` it is sometimes better not to work in memory, but to iterate through a dataset. Since `dataset.records()` is a generator, this can easily be done as follows: ```python +output = [] for record in dataset.records(): df = pd.DataFrame.from_records(record) + output.append(do_something(df)) ``` ### Custom Generators From 0174b8dcb9906e28aa954eea3f1450dacbf36e0c Mon Sep 17 00:00:00 2001 From: Timo Roest Date: Thu, 30 Apr 2020 10:10:51 +0100 Subject: [PATCH 367/632] Section on troubleshooting & manual parsing. --- docs/user-guide/pandas.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/docs/user-guide/pandas.md b/docs/user-guide/pandas.md index bde21753..23451b22 100644 --- a/docs/user-guide/pandas.md +++ b/docs/user-guide/pandas.md @@ -36,7 +36,8 @@ inside the dataframe. You can use traditional methods in pandas to deal with thi or extracting specific elements. ### Loading: Streaming -When working with large `datasets` it is sometimes better not to work in memory, but to iterate through a dataset. +When working with large `datasets` it is sometimes better not to work in memory, but to iterate through a dataset, rather +than load the entire dataset at once. Since `dataset.records()` is a generator, this can easily be done as follows: ```python output = [] @@ -156,3 +157,26 @@ def add_missing_attributes(dataset, df): add_missing_attributes(my_dataset, df) ``` + +## Troubleshooting + +When running into errors upon loading `dataset.records()` into a pandas dataframe, it is good to consider the following +steps. To extract a single record, the following code can be used to provide a minimal reproducible example: +```python +record = next(dataset.records()) +print(record) +``` + +### Parsing +Tamr allows for more variety in attribute names and contents than pandas does. In most cases pandas can load data +correctly, but it is possible to modify the parsing using a custom generator as shown above. An example below changes +an attribute name, and extracts only the first element: +```python +def custom_parser(record): + for record in dataset.records(): + record['pandas_column_name'] = record.pop('dataset_attribute_name') + record['first_element_of_column'] = record['multi_value_column'][0] + yield record + +df = pd.DataFrame.from_records(custom_parser(dataset)) +``` \ No newline at end of file From a8315b470ac6cf6bffaaaffce130188ee85eb888 Mon Sep 17 00:00:00 2001 From: Timo Date: Thu, 30 Apr 2020 13:35:58 +0100 Subject: [PATCH 368/632] Correction by Dominick Co-authored-by: Dominick Olivito --- docs/user-guide/pandas.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user-guide/pandas.md b/docs/user-guide/pandas.md index 23451b22..9fa3fb79 100644 --- a/docs/user-guide/pandas.md +++ b/docs/user-guide/pandas.md @@ -172,11 +172,11 @@ Tamr allows for more variety in attribute names and contents than pandas does. I correctly, but it is possible to modify the parsing using a custom generator as shown above. An example below changes an attribute name, and extracts only the first element: ```python -def custom_parser(record): +def custom_parser(dataset): for record in dataset.records(): record['pandas_column_name'] = record.pop('dataset_attribute_name') record['first_element_of_column'] = record['multi_value_column'][0] yield record df = pd.DataFrame.from_records(custom_parser(dataset)) -``` \ No newline at end of file +``` From 53a81a15f11df068538f5b4e7b83a26ace4f8a9a Mon Sep 17 00:00:00 2001 From: Timo Roest Date: Thu, 30 Apr 2020 14:53:44 +0100 Subject: [PATCH 369/632] Clarified with streaming for data loading. --- docs/user-guide/pandas.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user-guide/pandas.md b/docs/user-guide/pandas.md index 9fa3fb79..ba6132f4 100644 --- a/docs/user-guide/pandas.md +++ b/docs/user-guide/pandas.md @@ -42,8 +42,8 @@ Since `dataset.records()` is a generator, this can easily be done as follows: ```python output = [] for record in dataset.records(): - df = pd.DataFrame.from_records(record) - output.append(do_something(df)) + single_record_df = pd.DataFrame.from_records(record) + output.append(do_something(single_record_df)) ``` ### Custom Generators From 4a1f8af6f9bfa77d81eb0cf5e8984a03e9e42118 Mon Sep 17 00:00:00 2001 From: Timo Roest Date: Thu, 30 Apr 2020 15:01:32 +0100 Subject: [PATCH 370/632] fixed instance of single_record_df --- docs/user-guide/pandas.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/user-guide/pandas.md b/docs/user-guide/pandas.md index ba6132f4..ac513f4f 100644 --- a/docs/user-guide/pandas.md +++ b/docs/user-guide/pandas.md @@ -128,9 +128,9 @@ For larger datasets it might be better to stream the data and apply changes whil This way the full dataset does not need to be loaded into memory. ```python for record in dataset.records(): - df = pd.DataFrame.from_records(record) - df['column_to_change'] = 'new_value' - dataset.upsert_from_dataframe(df, primary_key_name='primary_key') + single_record_df = pd.DataFrame.from_records(record) + single_record_df['column_to_change'] = 'new_value' + dataset.upsert_from_dataframe(single_record_df, primary_key_name='primary_key') ``` ### Adding Attributes When making changes to dataframes, new dataframe columns are not automatically created as attributes when upserting From c1ed99958afc5e5971204380fb8ee5f1624d6371 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Wed, 6 May 2020 16:26:36 -0400 Subject: [PATCH 371/632] Changed imports to be absolute everywhere. --- tamr_client/attributes/attribute.py | 52 +++++++++++++----------- tamr_client/attributes/attribute_type.py | 9 ++-- tamr_client/attributes/subattribute.py | 17 +++++--- tamr_client/attributes/type_alias.py | 14 +++---- tamr_client/datasets/dataframe.py | 13 +++--- tamr_client/datasets/dataset.py | 17 ++++---- tamr_client/datasets/record.py | 16 ++++---- tamr_client/mastering/__init__.py | 1 - tamr_client/mastering/project.py | 8 ++-- tamr_client/project.py | 21 ++++++---- tamr_client/url.py | 8 ++-- 11 files changed, 100 insertions(+), 76 deletions(-) diff --git a/tamr_client/attributes/attribute.py b/tamr_client/attributes/attribute.py index 31a243be..90d1d0d7 100644 --- a/tamr_client/attributes/attribute.py +++ b/tamr_client/attributes/attribute.py @@ -5,7 +5,13 @@ from dataclasses import dataclass, field, replace from typing import Optional, Tuple -import tamr_client as tc +from tamr_client.url import URL +from tamr_client.attributes.attribute_type import AttributeType +from tamr_client.attributes import attribute_type +from tamr_client.attributes import type_alias +from tamr_client.session import Session +from tamr_client.datasets.dataset import Dataset +import tamr_client.response as response from tamr_client.types import JsonDict _RESERVED_NAMES = frozenset( @@ -60,15 +66,15 @@ class Attribute: description """ - url: tc.URL + url: URL name: str - type: tc.AttributeType + type: AttributeType is_nullable: bool _json: JsonDict = field(compare=False, repr=False) description: Optional[str] = None -def from_resource_id(session: tc.Session, dataset: tc.Dataset, id: str) -> Attribute: +def from_resource_id(session: Session, dataset: Dataset, id: str) -> Attribute: """Get attribute by resource ID Fetches attribute from Tamr server @@ -86,7 +92,7 @@ def from_resource_id(session: tc.Session, dataset: tc.Dataset, id: str) -> Attri return _from_url(session, url) -def _from_url(session: tc.Session, url: tc.URL) -> Attribute: +def _from_url(session: Session, url: URL) -> Attribute: """Get attribute by URL Fetches attribute from Tamr server @@ -102,11 +108,11 @@ def _from_url(session: tc.Session, url: tc.URL) -> Attribute: r = session.get(str(url)) if r.status_code == 404: raise AttributeNotFound(str(url)) - data = tc.response.successful(r).json() + data = response.successful(r).json() return _from_json(url, data) -def _from_json(url: tc.URL, data: JsonDict) -> Attribute: +def _from_json(url: URL, data: JsonDict) -> Attribute: """Make attribute from JSON data (deserialize) Args: @@ -119,12 +125,12 @@ def _from_json(url: tc.URL, data: JsonDict) -> Attribute: name=cp["name"], description=cp.get("description"), is_nullable=cp["isNullable"], - type=tc.attribute_type.from_json(cp["type"]), + type = attribute_type.from_json(cp["type"]), _json=cp, ) -def from_dataset_all(session: tc.Session, dataset: tc.Dataset) -> Tuple[Attribute, ...]: +def from_dataset_all(session: Session, dataset: Dataset) -> Tuple[Attribute, ...]: """Get all attributes from a dataset Args: @@ -138,7 +144,7 @@ def from_dataset_all(session: tc.Session, dataset: tc.Dataset) -> Tuple[Attribut """ attrs_url = replace(dataset.url, path=dataset.url.path + "/attributes") r = session.get(str(attrs_url)) - attrs_json = tc.response.successful(r).json() + attrs_json = response.successful(r).json() attrs = [] for attr_json in attrs_json: @@ -160,7 +166,7 @@ def to_json(attr: Attribute) -> JsonDict: """ d = { "name": attr.name, - "type": tc.attribute_type.to_json(attr.type), + "type": attribute_type.to_json(attr.type), "isNullable": attr.is_nullable, } if attr.description is not None: @@ -169,12 +175,12 @@ def to_json(attr: Attribute) -> JsonDict: def create( - session: tc.Session, - dataset: tc.dataset.Dataset, + session: Session, + dataset: Dataset, *, name: str, is_nullable: bool, - type: tc.attribute_type.AttributeType = tc.attributes.type_alias.DEFAULT, + type: AttributeType = type_alias.DEFAULT, description: Optional[str] = None, ) -> Attribute: """Create an attribute @@ -212,12 +218,12 @@ def create( def _create( - session: tc.Session, - dataset: tc.dataset.Dataset, + session: Session, + dataset: Dataset, *, name: str, is_nullable: bool, - type: tc.attribute_type.AttributeType = tc.attributes.type_alias.DEFAULT, + type: AttributeType = type_alias.DEFAULT, description: Optional[str] = None, ) -> Attribute: """Same as `tc.attribute.create`, but does not check for reserved attribute @@ -228,7 +234,7 @@ def _create( body = { "name": name, - "type": tc.attribute_type.to_json(type), + "type": attribute_type.to_json(type), "isNullable": is_nullable, } if description is not None: @@ -237,13 +243,13 @@ def _create( r = session.post(str(attrs_url), json=body) if r.status_code == 409: raise AttributeExists(str(url)) - data = tc.response.successful(r).json() + data = response.successful(r).json() return _from_json(url, data) def update( - session: tc.Session, attribute: Attribute, *, description: Optional[str] = None + session: Session, attribute: Attribute, *, description: Optional[str] = None ) -> Attribute: """Update an existing attribute @@ -265,11 +271,11 @@ def update( r = session.put(str(attribute.url), json=updates) if r.status_code == 404: raise AttributeNotFound(str(attribute.url)) - data = tc.response.successful(r).json() + data = response.successful(r).json() return _from_json(attribute.url, data) -def delete(session: tc.Session, attribute: Attribute): +def delete(session: Session, attribute: Attribute): """Deletes an existing attribute Sends a deletion request to the Tamr server @@ -285,4 +291,4 @@ def delete(session: tc.Session, attribute: Attribute): r = session.delete(str(attribute.url)) if r.status_code == 404: raise AttributeNotFound(str(attribute.url)) - tc.response.successful(r) + response.successful(r) diff --git a/tamr_client/attributes/attribute_type.py b/tamr_client/attributes/attribute_type.py index 11db1e37..35814fbc 100644 --- a/tamr_client/attributes/attribute_type.py +++ b/tamr_client/attributes/attribute_type.py @@ -5,7 +5,8 @@ import logging from typing import ClassVar, Tuple, Union -import tamr_client as tc +from tamr_client.attributes.subattribute import SubAttribute +from tamr_client.attributes import subattribute from tamr_client.types import JsonDict logger = logging.getLogger(__name__) @@ -72,7 +73,7 @@ class Record: # NOTE(pcattori) sphinx_autodoc_typehints cannot handle recursive reference # docstring written manually _tag: ClassVar[str] = "RECORD" - attributes: Tuple[tc.SubAttribute, ...] + attributes: Tuple[SubAttribute, ...] ComplexType = Union[Array, Map, Record] @@ -121,7 +122,7 @@ def from_json(data: JsonDict) -> AttributeType: logger.error(f"JSON data: {repr(data)}") raise ValueError("Missing required field 'attributes' for Record type.") return Record( - attributes=tuple([tc.subattribute.from_json(attr) for attr in attributes]) + attributes=tuple([subattribute.from_json(attr) for attr in attributes]) ) else: logger.error(f"JSON data: {repr(data)}") @@ -146,7 +147,7 @@ def to_json(attr_type: AttributeType) -> JsonDict: return { "baseType": type(attr_type)._tag, "attributes": [ - tc.subattribute.to_json(attr) for attr in attr_type.attributes + subattribute.to_json(attr) for attr in attr_type.attributes ], } else: diff --git a/tamr_client/attributes/subattribute.py b/tamr_client/attributes/subattribute.py index 8b566ea4..01a1a89a 100644 --- a/tamr_client/attributes/subattribute.py +++ b/tamr_client/attributes/subattribute.py @@ -1,10 +1,13 @@ +"""This module and attribute_type depend on each other. + +""" from copy import deepcopy from dataclasses import dataclass -from typing import Optional +from typing import Optional, TYPE_CHECKING -import tamr_client as tc from tamr_client.types import JsonDict - +if TYPE_CHECKING: + from tamr_client.attributes.attribute_type import AttributeType @dataclass(frozen=True) class SubAttribute: @@ -20,7 +23,7 @@ class SubAttribute: """ name: str - type: "tc.AttributeType" + type: "AttributeType" is_nullable: bool description: Optional[str] = None @@ -31,11 +34,12 @@ def from_json(data: JsonDict) -> SubAttribute: Args: data: JSON data received from Tamr server. """ + from tamr_client.attributes import attribute_type cp = deepcopy(data) d = {} d["name"] = cp["name"] d["is_nullable"] = cp["isNullable"] - d["type"] = tc.attribute_type.from_json(cp["type"]) + d["type"] = attribute_type.from_json(cp["type"]) return SubAttribute(**d) @@ -45,9 +49,10 @@ def to_json(subattr: SubAttribute) -> JsonDict: Args: subattr: SubAttribute to serialize """ + from tamr_client.attributes import attribute_type d = { "name": subattr.name, - "type": tc.attribute_type.to_json(subattr.type), + "type": attribute_type.to_json(subattr.type), "isNullable": subattr.is_nullable, } if subattr.description is not None: diff --git a/tamr_client/attributes/type_alias.py b/tamr_client/attributes/type_alias.py index 5e68e940..9e18e8c7 100644 --- a/tamr_client/attributes/type_alias.py +++ b/tamr_client/attributes/type_alias.py @@ -1,20 +1,20 @@ -import tamr_client as tc +from tamr_client.attributes.subattribute import SubAttribute from tamr_client.attributes.attribute_type import Array, DOUBLE, Record, STRING DEFAULT: Array = Array(STRING) GEOSPATIAL: Record = Record( attributes=( - tc.SubAttribute(name="point", is_nullable=True, type=Array(DOUBLE)), - tc.SubAttribute(name="multiPoint", is_nullable=True, type=Array(Array(DOUBLE))), - tc.SubAttribute(name="lineString", is_nullable=True, type=Array(Array(DOUBLE))), - tc.SubAttribute( + SubAttribute(name="point", is_nullable=True, type=Array(DOUBLE)), + SubAttribute(name="multiPoint", is_nullable=True, type=Array(Array(DOUBLE))), + SubAttribute(name="lineString", is_nullable=True, type=Array(Array(DOUBLE))), + SubAttribute( name="multiLineString", is_nullable=True, type=Array(Array(Array(DOUBLE))) ), - tc.SubAttribute( + SubAttribute( name="polygon", is_nullable=True, type=Array(Array(Array(DOUBLE))) ), - tc.SubAttribute( + SubAttribute( name="multiPolygon", is_nullable=True, type=Array(Array(Array(Array(DOUBLE)))), diff --git a/tamr_client/datasets/dataframe.py b/tamr_client/datasets/dataframe.py index 2a7f907e..3d56278e 100644 --- a/tamr_client/datasets/dataframe.py +++ b/tamr_client/datasets/dataframe.py @@ -7,7 +7,9 @@ import pandas as pd -import tamr_client as tc +from tamr_client.session import Session +from tamr_client.datasets.dataset import Dataset +from tamr_client.datasets import record from tamr_client.types import JsonDict @@ -18,8 +20,8 @@ class AmbiguousPrimaryKey(Exception): def upsert( - session: tc.Session, - dataset: tc.Dataset, + session: Session, + dataset: Dataset, df: pd.DataFrame, *, primary_key_name: Optional[str] = None, @@ -35,6 +37,7 @@ def upsert( JSON response body from the server Raises: + requests.HTTPError: If an HTTP error is encountered requests.HTTPError: If an HTTP error is encountered PrimaryKeyNotFound: If `primary_key_name` is not a column in `df` or the index of `df` ValueError: If `primary_key_name` matches both a column in `df` and the index of `df` @@ -48,7 +51,7 @@ def upsert( f"Index {primary_key_name} has the same name as column {primary_key_name}" ) elif primary_key_name not in df.columns and primary_key_name != df.index.name: - raise tc.PrimaryKeyNotFound( + raise record.PrimaryKeyNotFound( f"Primary key: {primary_key_name} is not DataFrame index name: {df.index.name} or in DataFrame column names: {df.columns}" ) @@ -61,6 +64,6 @@ def upsert( records = ( {primary_key_name: pk, **json.loads(row)} for pk, row in serialized_records ) - return tc.datasets.record.upsert( + return record.upsert( session, dataset, records, primary_key_name=primary_key_name ) diff --git a/tamr_client/datasets/dataset.py b/tamr_client/datasets/dataset.py index ae26c0e0..8a3f30b6 100644 --- a/tamr_client/datasets/dataset.py +++ b/tamr_client/datasets/dataset.py @@ -5,7 +5,10 @@ from dataclasses import dataclass from typing import Optional, Tuple -import tamr_client as tc +from tamr_client.url import URL +from tamr_client.session import Session +from tamr_client.instance import Instance +import tamr_client.response as response from tamr_client.types import JsonDict @@ -28,13 +31,13 @@ class Dataset: key_attribute_names """ - url: tc.URL + url: URL name: str key_attribute_names: Tuple[str, ...] description: Optional[str] = None -def from_resource_id(session: tc.Session, instance: tc.Instance, id: str) -> Dataset: +def from_resource_id(session: Session, instance: Instance, id: str) -> Dataset: """Get dataset by resource ID Fetches dataset from Tamr server @@ -48,11 +51,11 @@ def from_resource_id(session: tc.Session, instance: tc.Instance, id: str) -> Dat Corresponds to a 404 HTTP error. requests.HTTPError: If any other HTTP error is encountered. """ - url = tc.URL(instance=instance, path=f"datasets/{id}") + url = URL(instance=instance, path=f"datasets/{id}") return _from_url(session, url) -def _from_url(session: tc.Session, url: tc.URL) -> Dataset: +def _from_url(session: Session, url: URL) -> Dataset: """Get dataset by URL Fetches dataset from Tamr server @@ -68,11 +71,11 @@ def _from_url(session: tc.Session, url: tc.URL) -> Dataset: r = session.get(str(url)) if r.status_code == 404: raise DatasetNotFound(str(url)) - data = tc.response.successful(r).json() + data = response.successful(r).json() return _from_json(url, data) -def _from_json(url: tc.URL, data: JsonDict) -> Dataset: +def _from_json(url: URL, data: JsonDict) -> Dataset: """Make dataset from JSON data (deserialize) Args: diff --git a/tamr_client/datasets/record.py b/tamr_client/datasets/record.py index bb8baa81..9ca5312c 100644 --- a/tamr_client/datasets/record.py +++ b/tamr_client/datasets/record.py @@ -7,7 +7,9 @@ import json from typing import cast, Dict, IO, Iterable, Optional -import tamr_client as tc +from tamr_client.session import Session +from tamr_client.datasets.dataset import Dataset +import tamr_client.response as response from tamr_client.types import JsonDict @@ -18,7 +20,7 @@ class PrimaryKeyNotFound(Exception): def _update( - session: tc.Session, dataset: tc.Dataset, updates: Iterable[Dict] + session: Session, dataset: Dataset, updates: Iterable[Dict] ) -> JsonDict: """Send a batch of record creations/updates/deletions to this dataset. You probably want to use :func:`~tamr_client.record.upsert` @@ -42,12 +44,12 @@ def _update( headers={"Content-Encoding": "utf-8"}, data=io_updates, ) - return tc.response.successful(r).json() + return response.successful(r).json() def upsert( - session: tc.Session, - dataset: tc.Dataset, + session: Session, + dataset: Dataset, records: Iterable[Dict], *, primary_key_name: Optional[str] = None, @@ -82,8 +84,8 @@ def upsert( def delete( - session: tc.Session, - dataset: tc.Dataset, + session: Session, + dataset: Dataset, records: Iterable[Dict], *, primary_key_name: Optional[str] = None, diff --git a/tamr_client/mastering/__init__.py b/tamr_client/mastering/__init__.py index 252ac0ca..c52d9c06 100644 --- a/tamr_client/mastering/__init__.py +++ b/tamr_client/mastering/__init__.py @@ -3,6 +3,5 @@ Tamr - Mastering See https://docs.tamr.com/docs/overall-workflow-mastering """ - from tamr_client.mastering.project import Project import tamr_client.mastering.project diff --git a/tamr_client/mastering/project.py b/tamr_client/mastering/project.py index 04001083..7ced8dd6 100644 --- a/tamr_client/mastering/project.py +++ b/tamr_client/mastering/project.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Optional -import tamr_client as tc +from tamr_client.url import URL from tamr_client.types import JsonDict @@ -17,18 +17,18 @@ class Project: description """ - url: tc.URL + url: URL name: str description: Optional[str] = None -def _from_json(url: tc.URL, data: JsonDict) -> Project: +def _from_json(url: URL, data: JsonDict) -> Project: """Make mastering project from JSON data (deserialize) Args: url: Project URL data: Project JSON data from Tamr server """ - return tc.mastering.Project( + return Project( url, name=data["name"], description=data.get("description") ) diff --git a/tamr_client/project.py b/tamr_client/project.py index 7ccf5466..48dc1ad4 100644 --- a/tamr_client/project.py +++ b/tamr_client/project.py @@ -1,9 +1,14 @@ from typing import Union -import tamr_client as tc +from tamr_client.session import Session +from tamr_client.instance import Instance +from tamr_client.url import URL +import tamr_client.response as response +from tamr_client.mastering.project import Project as MasteringProject +import tamr_client.mastering.project as mastering_project from tamr_client.types import JsonDict -Project = Union[tc.mastering.Project] +Project = Union[MasteringProject] class NotFound(Exception): @@ -13,7 +18,7 @@ class NotFound(Exception): pass -def from_resource_id(session: tc.Session, instance: tc.Instance, id: str) -> Project: +def from_resource_id(session: Session, instance: Instance, id: str) -> Project: """Get project by resource ID Fetches project from Tamr server @@ -27,11 +32,11 @@ def from_resource_id(session: tc.Session, instance: tc.Instance, id: str) -> Pro Corresponds to a 404 HTTP error. requests.HTTPError: If any other HTTP error is encountered. """ - url = tc.URL(instance=instance, path=f"projects/{id}") + url = URL(instance=instance, path=f"projects/{id}") return _from_url(session, url) -def _from_url(session: tc.Session, url: tc.URL) -> Project: +def _from_url(session: Session, url: URL) -> Project: """Get project by URL Fetches project from Tamr server @@ -47,11 +52,11 @@ def _from_url(session: tc.Session, url: tc.URL) -> Project: r = session.get(str(url)) if r.status_code == 404: raise NotFound(str(url)) - data = tc.response.successful(r).json() + data = response.successful(r).json() return _from_json(url, data) -def _from_json(url: tc.URL, data: JsonDict) -> Project: +def _from_json(url: URL, data: JsonDict) -> Project: """Make project from JSON data (deserialize) Args: @@ -60,6 +65,6 @@ def _from_json(url: tc.URL, data: JsonDict) -> Project: """ proj_type = data["type"] if proj_type == "DEDUP": - return tc.mastering.project._from_json(url, data) + return mastering_project._from_json(url, data) else: raise ValueError(f"Unrecognized project type '{proj_type}' in {repr(data)}") diff --git a/tamr_client/url.py b/tamr_client/url.py index 4a110cdb..b3e8acdf 100644 --- a/tamr_client/url.py +++ b/tamr_client/url.py @@ -1,14 +1,14 @@ from dataclasses import dataclass -import tamr_client as tc - +from tamr_client.instance import Instance +import tamr_client.instance as instance @dataclass(frozen=True) class URL: path: str - instance: tc.Instance = tc.Instance() + instance: Instance = Instance() base_path: str = "api/versioned/v1" def __str__(self): - origin = tc.instance.origin(self.instance) + origin = instance.origin(self.instance) return f"{origin}/{self.base_path}/{self.path}" From 3c57ae3befb2c83c39f4eb2b133b85d8fa5539ed Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Wed, 6 May 2020 17:34:44 -0400 Subject: [PATCH 372/632] fixed linting issues --- tamr_client/attributes/attribute.py | 8 ++++---- tamr_client/attributes/attribute_type.py | 6 ++---- tamr_client/attributes/subattribute.py | 4 ++++ tamr_client/attributes/type_alias.py | 2 +- tamr_client/datasets/dataframe.py | 8 +++----- tamr_client/datasets/dataset.py | 4 ++-- tamr_client/datasets/record.py | 6 ++---- tamr_client/mastering/project.py | 6 ++---- tamr_client/project.py | 6 +++--- tamr_client/url.py | 3 ++- 10 files changed, 25 insertions(+), 28 deletions(-) diff --git a/tamr_client/attributes/attribute.py b/tamr_client/attributes/attribute.py index 90d1d0d7..bbdb9794 100644 --- a/tamr_client/attributes/attribute.py +++ b/tamr_client/attributes/attribute.py @@ -5,12 +5,12 @@ from dataclasses import dataclass, field, replace from typing import Optional, Tuple -from tamr_client.url import URL -from tamr_client.attributes.attribute_type import AttributeType from tamr_client.attributes import attribute_type +from tamr_client.attributes.attribute_type import AttributeType +from tamr_client.url import URL from tamr_client.attributes import type_alias -from tamr_client.session import Session from tamr_client.datasets.dataset import Dataset +from tamr_client.session import Session import tamr_client.response as response from tamr_client.types import JsonDict @@ -125,7 +125,7 @@ def _from_json(url: URL, data: JsonDict) -> Attribute: name=cp["name"], description=cp.get("description"), is_nullable=cp["isNullable"], - type = attribute_type.from_json(cp["type"]), + type=attribute_type.from_json(cp["type"]), _json=cp, ) diff --git a/tamr_client/attributes/attribute_type.py b/tamr_client/attributes/attribute_type.py index 35814fbc..d1a27234 100644 --- a/tamr_client/attributes/attribute_type.py +++ b/tamr_client/attributes/attribute_type.py @@ -5,8 +5,8 @@ import logging from typing import ClassVar, Tuple, Union -from tamr_client.attributes.subattribute import SubAttribute from tamr_client.attributes import subattribute +from tamr_client.attributes.subattribute import SubAttribute from tamr_client.types import JsonDict logger = logging.getLogger(__name__) @@ -146,9 +146,7 @@ def to_json(attr_type: AttributeType) -> JsonDict: return { "baseType": type(attr_type)._tag, - "attributes": [ - subattribute.to_json(attr) for attr in attr_type.attributes - ], + "attributes": [subattribute.to_json(attr) for attr in attr_type.attributes], } else: raise TypeError(attr_type) diff --git a/tamr_client/attributes/subattribute.py b/tamr_client/attributes/subattribute.py index 01a1a89a..de93b88f 100644 --- a/tamr_client/attributes/subattribute.py +++ b/tamr_client/attributes/subattribute.py @@ -6,9 +6,11 @@ from typing import Optional, TYPE_CHECKING from tamr_client.types import JsonDict + if TYPE_CHECKING: from tamr_client.attributes.attribute_type import AttributeType + @dataclass(frozen=True) class SubAttribute: """An attribute which is itself a property of another attribute. @@ -35,6 +37,7 @@ def from_json(data: JsonDict) -> SubAttribute: data: JSON data received from Tamr server. """ from tamr_client.attributes import attribute_type + cp = deepcopy(data) d = {} d["name"] = cp["name"] @@ -50,6 +53,7 @@ def to_json(subattr: SubAttribute) -> JsonDict: subattr: SubAttribute to serialize """ from tamr_client.attributes import attribute_type + d = { "name": subattr.name, "type": attribute_type.to_json(subattr.type), diff --git a/tamr_client/attributes/type_alias.py b/tamr_client/attributes/type_alias.py index 9e18e8c7..fa418bfb 100644 --- a/tamr_client/attributes/type_alias.py +++ b/tamr_client/attributes/type_alias.py @@ -1,5 +1,5 @@ -from tamr_client.attributes.subattribute import SubAttribute from tamr_client.attributes.attribute_type import Array, DOUBLE, Record, STRING +from tamr_client.attributes.subattribute import SubAttribute DEFAULT: Array = Array(STRING) diff --git a/tamr_client/datasets/dataframe.py b/tamr_client/datasets/dataframe.py index 3d56278e..9d2e87d0 100644 --- a/tamr_client/datasets/dataframe.py +++ b/tamr_client/datasets/dataframe.py @@ -7,9 +7,9 @@ import pandas as pd -from tamr_client.session import Session +import tamr_client.datasets.record as record from tamr_client.datasets.dataset import Dataset -from tamr_client.datasets import record +from tamr_client.session import Session from tamr_client.types import JsonDict @@ -64,6 +64,4 @@ def upsert( records = ( {primary_key_name: pk, **json.loads(row)} for pk, row in serialized_records ) - return record.upsert( - session, dataset, records, primary_key_name=primary_key_name - ) + return record.upsert(session, dataset, records, primary_key_name=primary_key_name) diff --git a/tamr_client/datasets/dataset.py b/tamr_client/datasets/dataset.py index 8a3f30b6..678d2e2b 100644 --- a/tamr_client/datasets/dataset.py +++ b/tamr_client/datasets/dataset.py @@ -5,9 +5,9 @@ from dataclasses import dataclass from typing import Optional, Tuple -from tamr_client.url import URL -from tamr_client.session import Session from tamr_client.instance import Instance +from tamr_client.session import Session +from tamr_client.url import URL import tamr_client.response as response from tamr_client.types import JsonDict diff --git a/tamr_client/datasets/record.py b/tamr_client/datasets/record.py index 9ca5312c..afc86e94 100644 --- a/tamr_client/datasets/record.py +++ b/tamr_client/datasets/record.py @@ -7,8 +7,8 @@ import json from typing import cast, Dict, IO, Iterable, Optional -from tamr_client.session import Session from tamr_client.datasets.dataset import Dataset +from tamr_client.session import Session import tamr_client.response as response from tamr_client.types import JsonDict @@ -19,9 +19,7 @@ class PrimaryKeyNotFound(Exception): pass -def _update( - session: Session, dataset: Dataset, updates: Iterable[Dict] -) -> JsonDict: +def _update(session: Session, dataset: Dataset, updates: Iterable[Dict]) -> JsonDict: """Send a batch of record creations/updates/deletions to this dataset. You probably want to use :func:`~tamr_client.record.upsert` or :func:`~tamr_client.record.delete` instead. diff --git a/tamr_client/mastering/project.py b/tamr_client/mastering/project.py index 7ced8dd6..4487939c 100644 --- a/tamr_client/mastering/project.py +++ b/tamr_client/mastering/project.py @@ -1,8 +1,8 @@ from dataclasses import dataclass from typing import Optional -from tamr_client.url import URL from tamr_client.types import JsonDict +from tamr_client.url import URL @dataclass(frozen=True) @@ -29,6 +29,4 @@ def _from_json(url: URL, data: JsonDict) -> Project: url: Project URL data: Project JSON data from Tamr server """ - return Project( - url, name=data["name"], description=data.get("description") - ) + return Project(url, name=data["name"], description=data.get("description")) diff --git a/tamr_client/project.py b/tamr_client/project.py index 48dc1ad4..957e4898 100644 --- a/tamr_client/project.py +++ b/tamr_client/project.py @@ -1,10 +1,10 @@ from typing import Union -from tamr_client.session import Session from tamr_client.instance import Instance -from tamr_client.url import URL -import tamr_client.response as response +from tamr_client.session import Session from tamr_client.mastering.project import Project as MasteringProject +import tamr_client.response as response +from tamr_client.url import URL import tamr_client.mastering.project as mastering_project from tamr_client.types import JsonDict diff --git a/tamr_client/url.py b/tamr_client/url.py index b3e8acdf..28d4cb52 100644 --- a/tamr_client/url.py +++ b/tamr_client/url.py @@ -1,7 +1,8 @@ from dataclasses import dataclass -from tamr_client.instance import Instance import tamr_client.instance as instance +from tamr_client.instance import Instance + @dataclass(frozen=True) class URL: From c8077d23c684d8d3e198daf9e19c6b85cbf18314 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Wed, 6 May 2020 17:45:35 -0400 Subject: [PATCH 373/632] more lint --- tamr_client/attributes/attribute.py | 6 +++--- tamr_client/datasets/dataframe.py | 2 +- tamr_client/datasets/dataset.py | 2 +- tamr_client/datasets/record.py | 2 +- tamr_client/project.py | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tamr_client/attributes/attribute.py b/tamr_client/attributes/attribute.py index bbdb9794..00d37bd4 100644 --- a/tamr_client/attributes/attribute.py +++ b/tamr_client/attributes/attribute.py @@ -5,13 +5,13 @@ from dataclasses import dataclass, field, replace from typing import Optional, Tuple -from tamr_client.attributes import attribute_type +import tamr_client.attributes.attribute_type as attribute_type +import tamr_client.attributes.type_alias as type_alias from tamr_client.attributes.attribute_type import AttributeType from tamr_client.url import URL -from tamr_client.attributes import type_alias from tamr_client.datasets.dataset import Dataset -from tamr_client.session import Session import tamr_client.response as response +from tamr_client.session import Session from tamr_client.types import JsonDict _RESERVED_NAMES = frozenset( diff --git a/tamr_client/datasets/dataframe.py b/tamr_client/datasets/dataframe.py index 9d2e87d0..55122881 100644 --- a/tamr_client/datasets/dataframe.py +++ b/tamr_client/datasets/dataframe.py @@ -7,8 +7,8 @@ import pandas as pd -import tamr_client.datasets.record as record from tamr_client.datasets.dataset import Dataset +import tamr_client.datasets.record as record from tamr_client.session import Session from tamr_client.types import JsonDict diff --git a/tamr_client/datasets/dataset.py b/tamr_client/datasets/dataset.py index 678d2e2b..c4f87efd 100644 --- a/tamr_client/datasets/dataset.py +++ b/tamr_client/datasets/dataset.py @@ -7,8 +7,8 @@ from tamr_client.instance import Instance from tamr_client.session import Session -from tamr_client.url import URL import tamr_client.response as response +from tamr_client.url import URL from tamr_client.types import JsonDict diff --git a/tamr_client/datasets/record.py b/tamr_client/datasets/record.py index afc86e94..2a4cfa37 100644 --- a/tamr_client/datasets/record.py +++ b/tamr_client/datasets/record.py @@ -8,8 +8,8 @@ from typing import cast, Dict, IO, Iterable, Optional from tamr_client.datasets.dataset import Dataset -from tamr_client.session import Session import tamr_client.response as response +from tamr_client.session import Session from tamr_client.types import JsonDict diff --git a/tamr_client/project.py b/tamr_client/project.py index 957e4898..14030ed5 100644 --- a/tamr_client/project.py +++ b/tamr_client/project.py @@ -1,11 +1,11 @@ from typing import Union +import tamr_client.mastering.project as mastering_project +from tamr_client.mastering.project import Project as MasteringProject from tamr_client.instance import Instance from tamr_client.session import Session -from tamr_client.mastering.project import Project as MasteringProject import tamr_client.response as response from tamr_client.url import URL -import tamr_client.mastering.project as mastering_project from tamr_client.types import JsonDict Project = Union[MasteringProject] From 584d99bf3e8ff5c0c364c7c5adcbdbeb915eb16f Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Wed, 6 May 2020 18:39:51 -0400 Subject: [PATCH 374/632] actually linted this time... --- tamr_client/attributes/attribute.py | 5 +++-- tamr_client/datasets/dataset.py | 4 ++-- tamr_client/project.py | 7 ++++--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/tamr_client/attributes/attribute.py b/tamr_client/attributes/attribute.py index 00d37bd4..b0a4f637 100644 --- a/tamr_client/attributes/attribute.py +++ b/tamr_client/attributes/attribute.py @@ -6,13 +6,14 @@ from typing import Optional, Tuple import tamr_client.attributes.attribute_type as attribute_type -import tamr_client.attributes.type_alias as type_alias from tamr_client.attributes.attribute_type import AttributeType -from tamr_client.url import URL +import tamr_client.attributes.type_alias as type_alias from tamr_client.datasets.dataset import Dataset import tamr_client.response as response from tamr_client.session import Session from tamr_client.types import JsonDict +from tamr_client.url import URL + _RESERVED_NAMES = frozenset( [ diff --git a/tamr_client/datasets/dataset.py b/tamr_client/datasets/dataset.py index c4f87efd..95fd7ad0 100644 --- a/tamr_client/datasets/dataset.py +++ b/tamr_client/datasets/dataset.py @@ -6,10 +6,10 @@ from typing import Optional, Tuple from tamr_client.instance import Instance -from tamr_client.session import Session import tamr_client.response as response -from tamr_client.url import URL +from tamr_client.session import Session from tamr_client.types import JsonDict +from tamr_client.url import URL class DatasetNotFound(Exception): diff --git a/tamr_client/project.py b/tamr_client/project.py index 14030ed5..7c9c0f20 100644 --- a/tamr_client/project.py +++ b/tamr_client/project.py @@ -1,12 +1,13 @@ from typing import Union +from tamr_client.instance import Instance import tamr_client.mastering.project as mastering_project from tamr_client.mastering.project import Project as MasteringProject -from tamr_client.instance import Instance -from tamr_client.session import Session import tamr_client.response as response -from tamr_client.url import URL +from tamr_client.session import Session from tamr_client.types import JsonDict +from tamr_client.url import URL + Project = Union[MasteringProject] From 0892ce35d78c4681ff0a2c559f7daeed788ac96f Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Fri, 15 May 2020 15:31:49 -0400 Subject: [PATCH 375/632] Renamed datasets to dataset. --- tamr_client/attributes/attribute.py | 2 +- tamr_client/{datasets => dataset}/__init__.py | 0 tamr_client/{datasets => dataset}/dataframe.py | 4 ++-- tamr_client/{datasets => dataset}/dataset.py | 0 tamr_client/{datasets => dataset}/record.py | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename tamr_client/{datasets => dataset}/__init__.py (100%) rename tamr_client/{datasets => dataset}/dataframe.py (96%) rename tamr_client/{datasets => dataset}/dataset.py (100%) rename tamr_client/{datasets => dataset}/record.py (99%) diff --git a/tamr_client/attributes/attribute.py b/tamr_client/attributes/attribute.py index b0a4f637..9743c4d8 100644 --- a/tamr_client/attributes/attribute.py +++ b/tamr_client/attributes/attribute.py @@ -8,7 +8,7 @@ import tamr_client.attributes.attribute_type as attribute_type from tamr_client.attributes.attribute_type import AttributeType import tamr_client.attributes.type_alias as type_alias -from tamr_client.datasets.dataset import Dataset +from tamr_client.dataset.dataset import Dataset import tamr_client.response as response from tamr_client.session import Session from tamr_client.types import JsonDict diff --git a/tamr_client/datasets/__init__.py b/tamr_client/dataset/__init__.py similarity index 100% rename from tamr_client/datasets/__init__.py rename to tamr_client/dataset/__init__.py diff --git a/tamr_client/datasets/dataframe.py b/tamr_client/dataset/dataframe.py similarity index 96% rename from tamr_client/datasets/dataframe.py rename to tamr_client/dataset/dataframe.py index 55122881..1a244e92 100644 --- a/tamr_client/datasets/dataframe.py +++ b/tamr_client/dataset/dataframe.py @@ -7,8 +7,8 @@ import pandas as pd -from tamr_client.datasets.dataset import Dataset -import tamr_client.datasets.record as record +from tamr_client.dataset.dataset import Dataset +import tamr_client.dataset.record as record from tamr_client.session import Session from tamr_client.types import JsonDict diff --git a/tamr_client/datasets/dataset.py b/tamr_client/dataset/dataset.py similarity index 100% rename from tamr_client/datasets/dataset.py rename to tamr_client/dataset/dataset.py diff --git a/tamr_client/datasets/record.py b/tamr_client/dataset/record.py similarity index 99% rename from tamr_client/datasets/record.py rename to tamr_client/dataset/record.py index 2a4cfa37..61ffca84 100644 --- a/tamr_client/datasets/record.py +++ b/tamr_client/dataset/record.py @@ -7,7 +7,7 @@ import json from typing import cast, Dict, IO, Iterable, Optional -from tamr_client.datasets.dataset import Dataset +from tamr_client.dataset.dataset import Dataset import tamr_client.response as response from tamr_client.session import Session from tamr_client.types import JsonDict From 91f0e68856ff70e4da2f53f320a81999fb37320a Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Fri, 15 May 2020 15:33:55 -0400 Subject: [PATCH 376/632] Reordered imports so records/dataframe isn't inaccessible after tamr_client.dataset.dataset as dataset --- tamr_client/__init__.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 83c0bca9..b275afaf 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -36,6 +36,14 @@ from tamr_client.session import Session import tamr_client.session as session +# records +from tamr_client.datasets.record import PrimaryKeyNotFound +import tamr_client.datasets.record as record + +# dataframe +from tamr_client.datasets.dataframe import AmbiguousPrimaryKey +import tamr_client.datasets.dataframe as dataframe + # datasets from tamr_client.datasets.dataset import Dataset, DatasetNotFound import tamr_client.datasets.dataset as dataset @@ -57,13 +65,5 @@ ) import tamr_client.attributes.attribute as attribute -# records -from tamr_client.datasets.record import PrimaryKeyNotFound -import tamr_client.datasets.record as record - -# dataframe -from tamr_client.datasets.dataframe import AmbiguousPrimaryKey -import tamr_client.datasets.dataframe as dataframe - import tamr_client.mastering as mastering import tamr_client.project as project From 7ca4c9467316a72494473087c5d4d5e80ebc1eba Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Fri, 15 May 2020 15:54:55 -0400 Subject: [PATCH 377/632] Had to change datasets to dataset in the tamr_client/__init__.py cause I deleted all changes to that so I could have two commits but forgot to add the changes back. --- tamr_client/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index b275afaf..1501c003 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -37,16 +37,16 @@ import tamr_client.session as session # records -from tamr_client.datasets.record import PrimaryKeyNotFound -import tamr_client.datasets.record as record +from tamr_client.dataset.record import PrimaryKeyNotFound +import tamr_client.dataset.record as record # dataframe -from tamr_client.datasets.dataframe import AmbiguousPrimaryKey -import tamr_client.datasets.dataframe as dataframe +from tamr_client.dataset.dataframe import AmbiguousPrimaryKey +import tamr_client.dataset.dataframe as dataframe # datasets -from tamr_client.datasets.dataset import Dataset, DatasetNotFound -import tamr_client.datasets.dataset as dataset +from tamr_client.dataset.dataset import Dataset, DatasetNotFound +import tamr_client.dataset.dataset as dataset # attributes from tamr_client.attributes.subattribute import SubAttribute From 30b2a817ffccd80c3ca765e9fcc7ebd447765b04 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Mon, 18 May 2020 11:49:23 -0400 Subject: [PATCH 378/632] Adjusted the docs files to reflect the naming change. Refactored the hoising of the dataset module --- docs/beta.md | 2 +- docs/beta/dataset.md | 5 +++++ docs/beta/{datasets => dataset}/dataframe.rst | 0 docs/beta/{datasets => dataset}/dataset.rst | 0 docs/beta/{datasets => dataset}/record.rst | 0 docs/beta/datasets.md | 5 ----- tamr_client/__init__.py | 8 ++++---- tamr_client/dataset/__init__.py | 8 +++++++- 8 files changed, 17 insertions(+), 11 deletions(-) create mode 100644 docs/beta/dataset.md rename docs/beta/{datasets => dataset}/dataframe.rst (100%) rename docs/beta/{datasets => dataset}/dataset.rst (100%) rename docs/beta/{datasets => dataset}/record.rst (100%) delete mode 100644 docs/beta/datasets.md diff --git a/docs/beta.md b/docs/beta.md index 2aa8177b..ecec02bb 100644 --- a/docs/beta.md +++ b/docs/beta.md @@ -7,7 +7,7 @@ * [Attributes](beta/attributes) * [Auth](beta/auth) - * [Dataset](beta/datasets) + * [Dataset](beta/dataset) * [Instance](beta/instance) * [Mastering](beta/mastering) * [Project](beta/project) diff --git a/docs/beta/dataset.md b/docs/beta/dataset.md new file mode 100644 index 00000000..c5ebb45e --- /dev/null +++ b/docs/beta/dataset.md @@ -0,0 +1,5 @@ +# Dataset + + * [Dataset](/beta/dataset/dataset) + * [Record](/beta/dataset/record) + * [Dataframe](/beta/dataset/dataframe) diff --git a/docs/beta/datasets/dataframe.rst b/docs/beta/dataset/dataframe.rst similarity index 100% rename from docs/beta/datasets/dataframe.rst rename to docs/beta/dataset/dataframe.rst diff --git a/docs/beta/datasets/dataset.rst b/docs/beta/dataset/dataset.rst similarity index 100% rename from docs/beta/datasets/dataset.rst rename to docs/beta/dataset/dataset.rst diff --git a/docs/beta/datasets/record.rst b/docs/beta/dataset/record.rst similarity index 100% rename from docs/beta/datasets/record.rst rename to docs/beta/dataset/record.rst diff --git a/docs/beta/datasets.md b/docs/beta/datasets.md deleted file mode 100644 index b1fe4338..00000000 --- a/docs/beta/datasets.md +++ /dev/null @@ -1,5 +0,0 @@ -# Datasets - - * [Dataset](/beta/datasets/dataset) - * [Record](/beta/datasets/record) - * [Dataframe](/beta/datasets/dataframe) \ No newline at end of file diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 1501c003..5b669a74 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -36,6 +36,10 @@ from tamr_client.session import Session import tamr_client.session as session +# datasets +from tamr_client.dataset import Dataset, DatasetNotFound +import tamr_client.dataset as dataset + # records from tamr_client.dataset.record import PrimaryKeyNotFound import tamr_client.dataset.record as record @@ -44,10 +48,6 @@ from tamr_client.dataset.dataframe import AmbiguousPrimaryKey import tamr_client.dataset.dataframe as dataframe -# datasets -from tamr_client.dataset.dataset import Dataset, DatasetNotFound -import tamr_client.dataset.dataset as dataset - # attributes from tamr_client.attributes.subattribute import SubAttribute import tamr_client.attributes.subattribute as subattribute diff --git a/tamr_client/dataset/__init__.py b/tamr_client/dataset/__init__.py index b2df60f9..b8332fa1 100644 --- a/tamr_client/dataset/__init__.py +++ b/tamr_client/dataset/__init__.py @@ -1,2 +1,8 @@ -# This __init__.py file is necessary for `mypy --package` # See https://github.com/python/mypy/issues/5759 +from tamr_client.dataset.dataset import ( + from_resource_id, +) +from tamr_client.dataset.dataset import Dataset +from tamr_client.dataset.dataset import DatasetNotFound +import tamr_client.dataset.record as record +import tamr_client.dataset.dataframe as dataframe From e0a0deaf8210ab47cb4293da6db74d2c65435445 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Mon, 18 May 2020 11:57:19 -0400 Subject: [PATCH 379/632] Changed order of imports in dataset/__init__.py --- tamr_client/dataset/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tamr_client/dataset/__init__.py b/tamr_client/dataset/__init__.py index b8332fa1..1d890297 100644 --- a/tamr_client/dataset/__init__.py +++ b/tamr_client/dataset/__init__.py @@ -1,8 +1,9 @@ # See https://github.com/python/mypy/issues/5759 +from tamr_client.dataset.dataset import Dataset + from tamr_client.dataset.dataset import ( from_resource_id, ) -from tamr_client.dataset.dataset import Dataset from tamr_client.dataset.dataset import DatasetNotFound -import tamr_client.dataset.record as record import tamr_client.dataset.dataframe as dataframe +import tamr_client.dataset.record as record From 096c0a917b13b5539f43495d15eceaac72d882e6 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Mon, 18 May 2020 12:50:33 -0400 Subject: [PATCH 380/632] I think this should lint correctly. --- tamr_client/dataset/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tamr_client/dataset/__init__.py b/tamr_client/dataset/__init__.py index 1d890297..c3d65dd0 100644 --- a/tamr_client/dataset/__init__.py +++ b/tamr_client/dataset/__init__.py @@ -1,9 +1,8 @@ +# flake8: noqa # See https://github.com/python/mypy/issues/5759 from tamr_client.dataset.dataset import Dataset -from tamr_client.dataset.dataset import ( - from_resource_id, -) +from tamr_client.dataset.dataset import from_resource_id from tamr_client.dataset.dataset import DatasetNotFound import tamr_client.dataset.dataframe as dataframe import tamr_client.dataset.record as record From 0f4584497602687549042db513fe1ae998135c83 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Mon, 18 May 2020 12:52:47 -0400 Subject: [PATCH 381/632] This should correctly import record and dataset from the new hierarchy in a way that satisfies python 3.6 --- tamr_client/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 5b669a74..fc001000 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -42,11 +42,11 @@ # records from tamr_client.dataset.record import PrimaryKeyNotFound -import tamr_client.dataset.record as record +from tamr_client.dataset import record # dataframe from tamr_client.dataset.dataframe import AmbiguousPrimaryKey -import tamr_client.dataset.dataframe as dataframe +from tamr_client.dataset import dataframe # attributes from tamr_client.attributes.subattribute import SubAttribute From 79c09a02bc1878c1c1bcd1071eb67febd8f44589 Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Mon, 18 May 2020 13:27:41 -0400 Subject: [PATCH 382/632] Update tamr_client/dataset/__init__.py Co-authored-by: Pedro Cattori --- tamr_client/dataset/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tamr_client/dataset/__init__.py b/tamr_client/dataset/__init__.py index c3d65dd0..05dddeb0 100644 --- a/tamr_client/dataset/__init__.py +++ b/tamr_client/dataset/__init__.py @@ -1,5 +1,4 @@ # flake8: noqa -# See https://github.com/python/mypy/issues/5759 from tamr_client.dataset.dataset import Dataset from tamr_client.dataset.dataset import from_resource_id From 79e414ae153311bde4d354b01517f6eccd6f62d2 Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Wed, 27 May 2020 13:37:12 -0400 Subject: [PATCH 383/632] change import so we don't have a problem with 3.6 --- tamr_client/dataset/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tamr_client/dataset/__init__.py b/tamr_client/dataset/__init__.py index 05dddeb0..7f5a4cc6 100644 --- a/tamr_client/dataset/__init__.py +++ b/tamr_client/dataset/__init__.py @@ -1,7 +1,5 @@ # flake8: noqa from tamr_client.dataset.dataset import Dataset - from tamr_client.dataset.dataset import from_resource_id from tamr_client.dataset.dataset import DatasetNotFound -import tamr_client.dataset.dataframe as dataframe -import tamr_client.dataset.record as record +from tamr_client.dataset import dataframe, record From 9a8ed2870128db8a0297f5012e4436185a2b9a30 Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Wed, 27 May 2020 13:40:34 -0400 Subject: [PATCH 384/632] Change the record import here as well. --- tamr_client/dataset/dataframe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_client/dataset/dataframe.py b/tamr_client/dataset/dataframe.py index 1a244e92..ca82ea83 100644 --- a/tamr_client/dataset/dataframe.py +++ b/tamr_client/dataset/dataframe.py @@ -8,7 +8,7 @@ import pandas as pd from tamr_client.dataset.dataset import Dataset -import tamr_client.dataset.record as record +from tamr_client.dataset import record from tamr_client.session import Session from tamr_client.types import JsonDict From ca7099d81d5da0c9267e7229c873eb8cd4b380a1 Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Wed, 27 May 2020 13:43:31 -0400 Subject: [PATCH 385/632] Changed import order to satisfy linter --- tamr_client/dataset/dataframe.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_client/dataset/dataframe.py b/tamr_client/dataset/dataframe.py index ca82ea83..ad514820 100644 --- a/tamr_client/dataset/dataframe.py +++ b/tamr_client/dataset/dataframe.py @@ -7,8 +7,8 @@ import pandas as pd -from tamr_client.dataset.dataset import Dataset from tamr_client.dataset import record +from tamr_client.dataset.dataset import Dataset from tamr_client.session import Session from tamr_client.types import JsonDict From 7fec6071a3d59f523789d49d29df9ebee049e84d Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 28 May 2020 12:49:28 -0400 Subject: [PATCH 386/632] Replace invoke with nox relevant nox features: - run multiple versions of python for each session (e.g. tests) - session.posargs for passing command-line arguments into nox sessions - for easy forwarding to underlying tools (e.g. pytest) --- noxfile.py | 56 ++++++++++++++++ poetry.lock | 176 ++++++++++++++++++++++++++++++++++++++++--------- pyproject.toml | 2 +- tasks.py | 57 ---------------- 4 files changed, 203 insertions(+), 88 deletions(-) create mode 100644 noxfile.py delete mode 100644 tasks.py diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 00000000..9d2578ad --- /dev/null +++ b/noxfile.py @@ -0,0 +1,56 @@ +from pathlib import Path + +import nox + +nox.options.reuse_existing_virtualenvs = True + + +def _find_packages(path: Path): + for pkg in path.iterdir(): + if pkg.is_dir() and len(list(pkg.glob("**/*.py"))) >= 1: + yield pkg + + +@nox.session(python="3.6") +def lint(session): + session.run("poetry", "install", external=True) + session.run("flake8", "--extend-exclude=.nox", ".") + + +@nox.session(python="3.6") +def format(session): + session.run("poetry", "install", external=True) + check = "" if "--fix" in session.posargs else "--check" + session.run("black", check, ".") + + +@nox.session(python="3.6") +def typecheck(session): + session.run("poetry", "install", external=True) + repo = Path(".") + + tc = repo / "tamr_client" + session.run("mypy", "--package", str(tc)) + + tc_tests = [str(x) for x in (repo / "tests" / "tamr_client").glob("**/*.py")] + session.run("mypy", *tc_tests) + + +@nox.session(python=["3.6", "3.7", "3.8"]) +def test(session): + session.run("poetry", "install", external=True) + session.run("pytest", *session.posargs, env={"TAMR_CLIENT_BETA": "1"}) + + +@nox.session(python="3.6") +def docs(session): + session.run("poetry", "install", external=True) + session.run( + "sphinx-build", + "-b", + "html", + "docs", + "docs/_build", + "-W", + env={"TAMR_CLIENT_BETA": "1"}, + ) diff --git a/poetry.lock b/poetry.lock index ae7c1a31..be1aa977 100644 --- a/poetry.lock +++ b/poetry.lock @@ -14,6 +14,22 @@ optional = false python-versions = "*" version = "1.4.3" +[[package]] +category = "dev" +description = "Bash tab completion for argparse" +name = "argcomplete" +optional = false +python-versions = "*" +version = "1.11.1" + +[package.dependencies] +[package.dependencies.importlib-metadata] +python = ">=3.6,<3.7" +version = ">=0.23,<2" + +[package.extras] +test = ["coverage", "flake8", "pexpect", "wheel"] + [[package]] category = "dev" description = "Atomic file writes." @@ -97,6 +113,17 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "0.4.1" +[[package]] +category = "dev" +description = "Log formatting with colors!" +name = "colorlog" +optional = false +python-versions = "*" +version = "4.1.0" + +[package.dependencies] +colorama = "*" + [[package]] category = "dev" description = "Python parser for the CommonMark Markdown spec" @@ -119,6 +146,14 @@ optional = false python-versions = "*" version = "0.6" +[[package]] +category = "dev" +description = "Distribution utilities" +name = "distlib" +optional = false +python-versions = "*" +version = "0.3.0" + [[package]] category = "dev" description = "Docutils -- Python Documentation Utilities" @@ -129,25 +164,28 @@ version = "0.14" [[package]] category = "dev" -description = "Discover and load entry points from installed packages." -name = "entrypoints" +description = "A platform independent file lock." +name = "filelock" optional = false -python-versions = ">=2.7" -version = "0.3" +python-versions = "*" +version = "3.0.12" [[package]] category = "dev" -description = "the modular source code checker: pep8, pyflakes and co" +description = "the modular source code checker: pep8 pyflakes and co" name = "flake8" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "3.7.7" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +version = "3.8.2" [package.dependencies] -entrypoints = ">=0.3.0,<0.4.0" mccabe = ">=0.6.0,<0.7.0" -pycodestyle = ">=2.5.0,<2.6.0" -pyflakes = ">=2.1.0,<2.2.0" +pycodestyle = ">=2.6.0a1,<2.7.0" +pyflakes = ">=2.2.0,<2.3.0" + +[package.dependencies.importlib-metadata] +python = "<3.8" +version = "*" [[package]] category = "dev" @@ -203,11 +241,24 @@ testing = ["packaging", "importlib-resources"] [[package]] category = "dev" -description = "Pythonic task execution" -name = "invoke" +description = "Read resources from Python packages" +marker = "python_version < \"3.7\"" +name = "importlib-resources" optional = false -python-versions = "*" -version = "1.4.0" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +version = "1.5.0" + +[package.dependencies] +[package.dependencies.importlib-metadata] +python = "<3.8" +version = "*" + +[package.dependencies.zipp] +python = "<3.8" +version = ">=0.4" + +[package.extras] +docs = ["sphinx", "rst.linker", "jaraco.packaging"] [[package]] category = "dev" @@ -271,6 +322,27 @@ optional = false python-versions = "*" version = "0.4.3" +[[package]] +category = "dev" +description = "Flexible test automation." +name = "nox" +optional = false +python-versions = ">=3.5" +version = "2020.5.24" + +[package.dependencies] +argcomplete = ">=1.9.4,<2.0" +colorlog = ">=2.6.1,<5.0.0" +py = ">=1.4.0,<2.0.0" +virtualenv = ">=14.0.0" + +[package.dependencies.importlib-metadata] +python = "<3.8" +version = "*" + +[package.extras] +tox_to_nox = ["jinja2", "tox"] + [[package]] category = "main" description = "NumPy is the fundamental package for array computing with Python." @@ -337,7 +409,7 @@ description = "Python style guide checker" name = "pycodestyle" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.5.0" +version = "2.6.0" [[package]] category = "dev" @@ -345,7 +417,7 @@ description = "passive checker of Python programs" name = "pyflakes" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.1.1" +version = "2.2.0" [[package]] category = "dev" @@ -638,6 +710,32 @@ brotli = ["brotlipy (>=0.6.0)"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] +[[package]] +category = "dev" +description = "Virtual Python Environment builder" +name = "virtualenv" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +version = "20.0.21" + +[package.dependencies] +appdirs = ">=1.4.3,<2" +distlib = ">=0.3.0,<1" +filelock = ">=3.0.0,<4" +six = ">=1.9.0,<2" + +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12,<2" + +[package.dependencies.importlib-resources] +python = "<3.7" +version = ">=1.0,<2" + +[package.extras] +docs = ["sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)", "proselint (>=0.10.2)"] +testing = ["pytest (>=4)", "coverage (>=5)", "coverage-enable-subprocess (>=1)", "pytest-xdist (>=1.31.0)", "pytest-mock (>=2)", "pytest-env (>=0.6.2)", "pytest-randomly (>=1)", "pytest-timeout", "packaging (>=20.0)", "xonsh (>=0.9.16)"] + [[package]] category = "dev" description = "Measures number of Terminal column cells of wide-character codes" @@ -663,7 +761,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["pathlib2", "contextlib2", "unittest2"] [metadata] -content-hash = "2ae183c44ed21ff4a404614166c233a4b27517026448e57a30129486edcbf922" +content-hash = "fbae3ed35d6137484f2633797b45e736fa08a758fe3509e5bab3564ec6e85d2a" python-versions = "^3.6.1" [metadata.files] @@ -675,6 +773,10 @@ appdirs = [ {file = "appdirs-1.4.3-py2.py3-none-any.whl", hash = "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"}, {file = "appdirs-1.4.3.tar.gz", hash = "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92"}, ] +argcomplete = [ + {file = "argcomplete-1.11.1-py2.py3-none-any.whl", hash = "sha256:890bdd1fcbb973ed73db241763e78b6d958580e588c2910b508c770a59ef37d7"}, + {file = "argcomplete-1.11.1.tar.gz", hash = "sha256:5ae7b601be17bf38a749ec06aa07fb04e7b6b5fc17906948dc1866e7facf3740"}, +] atomicwrites = [ {file = "atomicwrites-1.3.0-py2.py3-none-any.whl", hash = "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4"}, {file = "atomicwrites-1.3.0.tar.gz", hash = "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"}, @@ -707,6 +809,10 @@ colorama = [ {file = "colorama-0.4.1-py2.py3-none-any.whl", hash = "sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"}, {file = "colorama-0.4.1.tar.gz", hash = "sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d"}, ] +colorlog = [ + {file = "colorlog-4.1.0-py2.py3-none-any.whl", hash = "sha256:732c191ebbe9a353ec160d043d02c64ddef9028de8caae4cfa8bd49b6afed53e"}, + {file = "colorlog-4.1.0.tar.gz", hash = "sha256:30aaef5ab2a1873dec5da38fd6ba568fa761c9fa10b40241027fa3edea47f3d2"}, +] commonmark = [ {file = "commonmark-0.9.0-py2.py3-none-any.whl", hash = "sha256:14c3df31e8c9c463377e287b2a1eefaa6019ab97b22dad36e2f32be59d61d68d"}, {file = "commonmark-0.9.0.tar.gz", hash = "sha256:867fc5db078ede373ab811e16b6789e9d033b15ccd7296f370ca52d1ee792ce0"}, @@ -715,18 +821,21 @@ dataclasses = [ {file = "dataclasses-0.6-py3-none-any.whl", hash = "sha256:454a69d788c7fda44efd71e259be79577822f5e3f53f029a22d08004e951dc9f"}, {file = "dataclasses-0.6.tar.gz", hash = "sha256:6988bd2b895eef432d562370bb707d540f32f7360ab13da45340101bc2307d84"}, ] +distlib = [ + {file = "distlib-0.3.0.zip", hash = "sha256:2e166e231a26b36d6dfe35a48c4464346620f8645ed0ace01ee31822b288de21"}, +] docutils = [ {file = "docutils-0.14-py2-none-any.whl", hash = "sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6"}, {file = "docutils-0.14-py3-none-any.whl", hash = "sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6"}, {file = "docutils-0.14.tar.gz", hash = "sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274"}, ] -entrypoints = [ - {file = "entrypoints-0.3-py2.py3-none-any.whl", hash = "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19"}, - {file = "entrypoints-0.3.tar.gz", hash = "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"}, +filelock = [ + {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, + {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, ] flake8 = [ - {file = "flake8-3.7.7-py2.py3-none-any.whl", hash = "sha256:a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8"}, - {file = "flake8-3.7.7.tar.gz", hash = "sha256:859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661"}, + {file = "flake8-3.8.2-py2.py3-none-any.whl", hash = "sha256:ccaa799ef9893cebe69fdfefed76865aeaefbb94cb8545617b2298786a4de9a5"}, + {file = "flake8-3.8.2.tar.gz", hash = "sha256:c69ac1668e434d37a2d2880b3ca9aafd54b3a10a3ac1ab101d22f29e29cf8634"}, ] flake8-import-order = [ {file = "flake8-import-order-0.18.1.tar.gz", hash = "sha256:a28dc39545ea4606c1ac3c24e9d05c849c6e5444a50fb7e9cdd430fc94de6e92"}, @@ -747,10 +856,9 @@ importlib-metadata = [ {file = "importlib_metadata-1.3.0-py2.py3-none-any.whl", hash = "sha256:d95141fbfa7ef2ec65cfd945e2af7e5a6ddbd7c8d9a25e66ff3be8e3daf9f60f"}, {file = "importlib_metadata-1.3.0.tar.gz", hash = "sha256:073a852570f92da5f744a3472af1b61e28e9f78ccf0c9117658dc32b15de7b45"}, ] -invoke = [ - {file = "invoke-1.4.0-py2-none-any.whl", hash = "sha256:4668a4a594a47f2da2f0672ec2f7b1566f809cebf10bcd95ce2de9ecf39b95d1"}, - {file = "invoke-1.4.0-py3-none-any.whl", hash = "sha256:e04faba8ea7cdf6f5c912be42dcafd5c1074b7f2f306998992c4bfb40a9a690b"}, - {file = "invoke-1.4.0.tar.gz", hash = "sha256:ae7b4513638bde9afcda0825e9535599637a3f65bd819a27098356027bb17c8a"}, +importlib-resources = [ + {file = "importlib_resources-1.5.0-py2.py3-none-any.whl", hash = "sha256:85dc0b9b325ff78c8bef2e4ff42616094e16b98ebd5e3b50fe7e2f0bbcdcde49"}, + {file = "importlib_resources-1.5.0.tar.gz", hash = "sha256:6f87df66833e1942667108628ec48900e02a4ab4ad850e25fbf07cb17cf734ca"}, ] jinja2 = [ {file = "Jinja2-2.10.1-py2.py3-none-any.whl", hash = "sha256:14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b"}, @@ -814,6 +922,10 @@ mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] +nox = [ + {file = "nox-2020.5.24-py3-none-any.whl", hash = "sha256:c4509621fead99473a1401870e680b0aadadce5c88440f0532863595176d64c1"}, + {file = "nox-2020.5.24.tar.gz", hash = "sha256:61a55705736a1a73efbd18d5b262a43d55a1176546e0eb28b29064cfcffe26c0"}, +] numpy = [ {file = "numpy-1.18.2-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a1baa1dc8ecd88fb2d2a651671a84b9938461e8a8eed13e2f0a812a94084d1fa"}, {file = "numpy-1.18.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a244f7af80dacf21054386539699ce29bcc64796ed9850c99a34b41305630286"}, @@ -868,12 +980,12 @@ py = [ {file = "py-1.8.0.tar.gz", hash = "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"}, ] pycodestyle = [ - {file = "pycodestyle-2.5.0-py2.py3-none-any.whl", hash = "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56"}, - {file = "pycodestyle-2.5.0.tar.gz", hash = "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"}, + {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, + {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, ] pyflakes = [ - {file = "pyflakes-2.1.1-py2.py3-none-any.whl", hash = "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0"}, - {file = "pyflakes-2.1.1.tar.gz", hash = "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"}, + {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, + {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, ] pygments = [ {file = "Pygments-2.4.2-py2.py3-none-any.whl", hash = "sha256:71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127"}, @@ -1011,6 +1123,10 @@ urllib3 = [ {file = "urllib3-1.25.3-py2.py3-none-any.whl", hash = "sha256:b246607a25ac80bedac05c6f282e3cdaf3afb65420fd024ac94435cabe6e18d1"}, {file = "urllib3-1.25.3.tar.gz", hash = "sha256:dbe59173209418ae49d485b87d1681aefa36252ee85884c31346debd19463232"}, ] +virtualenv = [ + {file = "virtualenv-20.0.21-py2.py3-none-any.whl", hash = "sha256:a730548b27366c5e6cbdf6f97406d861cccece2e22275e8e1a757aeff5e00c70"}, + {file = "virtualenv-20.0.21.tar.gz", hash = "sha256:a116629d4e7f4d03433b8afa27f43deba09d48bc48f5ecefa4f015a178efb6cf"}, +] wcwidth = [ {file = "wcwidth-0.1.7-py2.py3-none-any.whl", hash = "sha256:f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c"}, {file = "wcwidth-0.1.7.tar.gz", hash = "sha256:3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e"}, diff --git a/pyproject.toml b/pyproject.toml index a8c2c800..31472e97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,8 +44,8 @@ sphinx_rtd_theme = "^0.4.3" recommonmark = "^0.6.0" sphinx-autodoc-typehints = "^1.8" pytest = "^5.3.2" -invoke = "^1.4.0" mypy = "^0.770" +nox = "^2020.5.24" [build-system] requires = ["poetry>=1.0"] diff --git a/tasks.py b/tasks.py deleted file mode 100644 index 77470a59..00000000 --- a/tasks.py +++ /dev/null @@ -1,57 +0,0 @@ -from pathlib import Path -import sys - -from invoke import task - -beta = "TAMR_CLIENT_BETA=1" - - -def _find_packages(path: Path): - for pkg in path.iterdir(): - if pkg.is_dir() and len(list(pkg.glob("**/*.py"))) >= 1: - yield pkg - - -@task -def lint(c): - c.run("poetry run flake8 .", echo=True, pty=True) - - -@task -def format(c, fix=False): - check = "" if fix else "--check" - c.run(f"poetry run black {check} .", echo=True, pty=True) - - -@task -def typecheck(c, warn=True): - exit_code = 0 - repo = Path(".") - - tc = repo / "tamr_client" - result = c.run(f"poetry run mypy --package {tc}", echo=True, pty=True, warn=warn) - if not result.exited == 0: - exit_code = result.exited - - tc_tests = " ".join( - str(x) for x in (repo / "tests" / "tamr_client").glob("**/*.py") - ) - c.run(f"poetry run mypy {tc_tests}", echo=True, pty=True, warn=warn) - if not result.exited == 0: - exit_code = result.exited - - sys.exit(exit_code) - - -@task -def test(c): - c.run(f"{beta} poetry run pytest", echo=True, pty=True) - - -@task -def docs(c): - c.run( - f"{beta} poetry run sphinx-build -b html docs docs/_build -W", - echo=True, - pty=True, - ) From ab9a9fc13a5bb0a94490b0c5487714fcb882565f Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 28 May 2020 12:52:35 -0400 Subject: [PATCH 387/632] Include local python version numbers For consistency across contributors. Note: CI is not currently guaranteed to match on patch version, only on major and minor verions. --- .gitignore | 3 --- .python-version | 3 +++ 2 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 .python-version diff --git a/.gitignore b/.gitignore index f7cb931e..b761bef2 100644 --- a/.gitignore +++ b/.gitignore @@ -86,9 +86,6 @@ target/ profile_default/ ipython_config.py -# pyenv -.python-version - # celery beat schedule file celerybeat-schedule diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..58689652 --- /dev/null +++ b/.python-version @@ -0,0 +1,3 @@ +3.6.10 +3.7.7 +3.8.2 \ No newline at end of file From 3bd166232ac77afd7e1144ac85bbf1647478aba4 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 28 May 2020 12:57:19 -0400 Subject: [PATCH 388/632] Use nox instead of invoke for CI Following methodology from https://cjolowicz.github.io/posts/hypermodern-python-06-ci-cd/ . Also, split `Lint` check into `Link` and `Format` checks. --- .github/workflows/ci.yml | 53 +++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1146a43..096326a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,33 +15,46 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - - name: Install Python + - name: Install python uses: actions/setup-python@v1.1.1 with: python-version: 3.6 - - name: Install Poetry - uses: dschep/install-poetry-action@v1.2 - - name: Install dependencies - run: poetry install + - name: Install poetry + run: pip install poetry==1.0.5 + - name: Install nox + run: pip install nox==2020.5.24 - name: Run flake8 - run: poetry run invoke lint + run: nox -s lint + + Format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Install python + uses: actions/setup-python@v1.1.1 + with: + python-version: 3.6 + - name: Install poetry + run: pip install poetry==1.0.5 + - name: Install nox + run: pip install nox==2020.5.24 - name: Run black - run: poetry run invoke format + run: nox -s format Typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - - name: Install Python + - name: Install python uses: actions/setup-python@v1.1.1 with: python-version: 3.6 - - name: Install Poetry - uses: dschep/install-poetry-action@v1.2 - - name: Install dependencies - run: poetry install + - name: Install poetry + run: pip install poetry==1.0.5 + - name: Install nox + run: pip install nox==2020.5.24 - name: Run mypy - run: poetry run invoke typecheck + run: nox -s typecheck Test: strategy: @@ -50,22 +63,22 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - - name: Install Python + - name: Install python uses: actions/setup-python@v1.1.1 with: python-version: ${{ matrix.python_version }} - - name: Install Poetry - uses: dschep/install-poetry-action@v1.2 - - name: Install dependencies - run: poetry install + - name: Install poetry + run: pip install poetry==1.0.5 + - name: Install nox + run: pip install nox==2020.5.24 - name: Run pytest - run: poetry run invoke test + run: nox -s test-${{ matrix.python_version }} Docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - - name: Install Python + - name: Install python uses: actions/setup-python@v1.1.1 with: python-version: 3.6 From 22c0e5dd847cb4b761837f3f85bd1346cac278f8 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 28 May 2020 13:45:05 -0400 Subject: [PATCH 389/632] Explain nox usage in contributor docs Also, simplify toolchain docs --- docs/contributor-guide/run-and-build.md | 58 +++++++++++++++++-------- docs/contributor-guide/toolchain.md | 17 ++++---- 2 files changed, 47 insertions(+), 28 deletions(-) diff --git a/docs/contributor-guide/run-and-build.md b/docs/contributor-guide/run-and-build.md index 48a99437..6e5812ff 100644 --- a/docs/contributor-guide/run-and-build.md +++ b/docs/contributor-guide/run-and-build.md @@ -1,11 +1,18 @@ # Run and Build -This project uses [invoke](http://www.pyinvoke.org/) as its task runner. +This project uses [nox](https://nox.thea.codes/en/stable/). -Since `invoke` will be running inside of a `poetry` environment, we recommend adding the following alias to your `.bashrc` / `.zshrc` to save you some keystrokes: +Since `nox` will be running inside of a `poetry` environment (to guarantee you are running the same version of `nox` as everyone else), we recommend adding the following alias to your `.bashrc` / `.zshrc` to save you some keystrokes: ```sh -alias pri='poetry run invoke' +alias prn='poetry run nox' +``` + +To run all checks: + +```sh +prn # with alias +poetry run nox # without alias ``` ## Linting & Formatting @@ -13,22 +20,22 @@ alias pri='poetry run invoke' To run linter: ```sh -pri lint # with alias -poetry run invoke lint # without alias +prn -s lint # with alias +poetry run nox -s lint # without alias ``` To run formatter: ```sh -pri format # with alias -poetry run invoke format # without alias +prn -s format # with alias +poetry run nox -s format # without alias ``` Run the formatter with the `--fix` flag to autofix formatting: ```sh -pri format --fix # with alias -poetry run invoke format --fix # without alias +prn -s format -- --fix # with alias +poetry run nox -s format -- --fix # without alias ``` ## Typechecks @@ -36,8 +43,8 @@ poetry run invoke format --fix # without alias To run typechecks: ```sh -pri typecheck # with alias -poetry run invoke typecheck # without alias +prn -s typecheck # with alias +poetry run nox -s typecheck # without alias ``` ## Tests @@ -45,14 +52,27 @@ poetry run invoke typecheck # without alias To run all tests: ```sh -pri test # with alias -poetry run invoke test # without alias +prn -s test # with alias +poetry run nox -s test # without alias ``` -To run specific tests, see [these pytest docs](https://docs.pytest.org/en/latest/usage.html#specifying-tests-selecting-tests) and run `pytest` explicitly: +--- + +To run tests for a specifc Python version e.g. 3.6: + +```sh +prn -s test-3.6 # with alias +poetry run nox -s test-3.6 # without alias +``` + +See [`nox --list`](https://nox.thea.codes/en/stable/tutorial.html#selecting-which-sessions-to-run) for more details. + +--- + +To run specific tests, see [these pytest docs](https://docs.pytest.org/en/latest/usage.html#specifying-tests-selecting-tests) and pass `pytest` args after `--` e.g.: ```sh -poetry run pytest tests/unit/test_attribute.py +prn -s test -- tests/unit/test_attribute.py ``` @@ -61,13 +81,13 @@ poetry run pytest tests/unit/test_attribute.py To build the docs: ```sh -pri docs # with alias -poetry run invoke docs # without alias +prn -s docs # with alias +poetry run nox -s docs # without alias ``` After docs are build, view them by: ```sh - open -a 'firefox' docs/_build/index.html # open in Firefox - open -a 'Google Chrome' docs/_build/index.html # open in Chrome +open -a 'firefox' docs/_build/index.html # open in Firefox +open -a 'Google Chrome' docs/_build/index.html # open in Chrome ``` diff --git a/docs/contributor-guide/toolchain.md b/docs/contributor-guide/toolchain.md index c4a17056..88fea3f2 100644 --- a/docs/contributor-guide/toolchain.md +++ b/docs/contributor-guide/toolchain.md @@ -9,21 +9,20 @@ see the [official documentation](https://poetry.eustace.io/). curl https://pyenv.run | bash ``` - 2. Use `pyenv` to install a compatible Python version (`3.6` or newer; e.g. `3.7.3`): + 2. Use `pyenv` to install all Python versions in [.python-version](https://github.com/Datatamer/tamr-client/blob/master/.python-version): - ```sh - pyenv install 3.7.3 - ``` - - 3. Set that Python version to be your version for this project(e.g. `3.7.3`): + [Automated tests](run-and-build) will use these Python versions. ```sh - pyenv shell 3.7.3 - python --version # check that version matches your specified version + cd tamr-client/ # or wherever you cloned Datatamer/tamr-client + + # run `pyenv install` for each line in `.python-version` + cat .python-version | xargs -L 1 pyenv install ``` - 4. Install `poetry` as [described here](https://poetry.eustace.io/docs/#installation): + 4. Install `poetry` with `python` 3.6+ as [described here](https://poetry.eustace.io/docs/#installation): ```sh + python --version # check that version is 3.6.9 curl -sSL https://raw.githubusercontent.com/sdispater/poetry/master/get-poetry.py | python ``` From e02b0d5cdfacbcf3e30c7b838b6f9f8150bd5db5 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 28 May 2020 14:16:12 -0400 Subject: [PATCH 390/632] Fix linting --- tests/mock_api/test_continuous_mastering.py | 2 +- tests/unit/test_attribute.py | 6 +++--- .../test_attribute_configuration_collection.py | 14 ++++++++------ tests/unit/test_binning_model.py | 6 +++--- tests/unit/test_categorization.py | 4 ++-- tests/unit/test_create_dataset.py | 2 +- tests/unit/test_create_project.py | 2 +- tests/unit/test_dataset_attributes.py | 2 +- tests/unit/test_dataset_geo.py | 12 ++++++------ tests/unit/test_dataset_records.py | 2 +- tests/unit/test_dataset_usage.py | 2 +- tests/unit/test_http_error.py | 2 +- tests/unit/test_project.py | 2 +- tests/unit/test_published_clusters_with_data.py | 2 +- tests/unit/test_record_clusters_with_data.py | 2 +- 15 files changed, 32 insertions(+), 30 deletions(-) diff --git a/tests/mock_api/test_continuous_mastering.py b/tests/mock_api/test_continuous_mastering.py index 98244dc9..4d1512cf 100644 --- a/tests/mock_api/test_continuous_mastering.py +++ b/tests/mock_api/test_continuous_mastering.py @@ -43,7 +43,7 @@ def test_continuous_mastering(): assert op.succeeded() estimate_url = ( - f"http://localhost:9100/api/versioned/v1/projects/1/estimatedPairCounts" + "http://localhost:9100/api/versioned/v1/projects/1/estimatedPairCounts" ) estimate_json = { "isUpToDate": "true", diff --git a/tests/unit/test_attribute.py b/tests/unit/test_attribute.py index 91825df6..2a297cec 100644 --- a/tests/unit/test_attribute.py +++ b/tests/unit/test_attribute.py @@ -65,8 +65,8 @@ def test_complex_type(self): @responses.activate def test_dataset_attributes(self): - dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/1" - attributes_url = f"http://localhost:9100/api/versioned/v1/datasets/1/attributes" + dataset_url = "http://localhost:9100/api/versioned/v1/datasets/1" + attributes_url = "http://localhost:9100/api/versioned/v1/datasets/1/attributes" responses.add(responses.GET, dataset_url, json=self._dataset_json) responses.add(responses.GET, attributes_url, json=self._attributes_json) dataset = self.tamr.datasets.by_resource_id("1") @@ -80,7 +80,7 @@ def test_dataset_attributes(self): @responses.activate def test_delete_attribute(self): - url = f"http://localhost:9100/api/versioned/v1/datasets/1/attributes/RowNum" + url = "http://localhost:9100/api/versioned/v1/datasets/1/attributes/RowNum" responses.add(responses.GET, url, json=self._attributes_json[0]) responses.add(responses.DELETE, url, status=204) responses.add(responses.GET, url, status=404) diff --git a/tests/unit/test_attribute_configuration_collection.py b/tests/unit/test_attribute_configuration_collection.py index 52fa58a3..1e81c0d9 100644 --- a/tests/unit/test_attribute_configuration_collection.py +++ b/tests/unit/test_attribute_configuration_collection.py @@ -22,7 +22,7 @@ def setUp(self): @responses.activate def test_by_relative_id(self): - ac_url = f"http://localhost:9100/api/versioned/v1/projects/1/attributeConfigurations/1" + ac_url = "http://localhost:9100/api/versioned/v1/projects/1/attributeConfigurations/1" alias = "projects/1/attributeConfigurations/" ac_test = AttributeConfigurationCollection(self.tamr, alias) expected = self.acc_json[0]["relativeId"] @@ -34,7 +34,7 @@ def test_by_relative_id(self): @responses.activate def test_by_resource_id(self): - ac_url = f"http://localhost:9100/api/versioned/v1/projects/1/attributeConfigurations/1" + ac_url = "http://localhost:9100/api/versioned/v1/projects/1/attributeConfigurations/1" alias = "projects/1/attributeConfigurations/" ac_test = AttributeConfigurationCollection(self.tamr, alias) expected = self.acc_json[0]["relativeId"] @@ -48,9 +48,9 @@ def create_callback(request, snoop): return 204, {}, json.dumps(self.created_json) url = ( - f"http://localhost:9100/api/versioned/v1/projects/1/attributeConfigurations" + "http://localhost:9100/api/versioned/v1/projects/1/attributeConfigurations" ) - project_url = f"http://localhost:9100/api/versioned/v1/projects/1" + project_url = "http://localhost:9100/api/versioned/v1/projects/1" responses.add(responses.GET, project_url, json=self.project_json) snoop_dict = {} responses.add_callback( @@ -73,7 +73,7 @@ def create_callback(request, snoop): return 204, {}, json.dumps(self.created_json) url = ( - f"http://localhost:9100/api/versioned/v1/projects/1/attributeConfigurations" + "http://localhost:9100/api/versioned/v1/projects/1/attributeConfigurations" ) snoop_dict = {} responses.add_callback( @@ -94,7 +94,9 @@ def create_callback(request, snoop): @responses.activate def test_stream(self): - ac_url = f"http://localhost:9100/api/versioned/v1/projects/1/attributeConfigurations/" + ac_url = ( + "http://localhost:9100/api/versioned/v1/projects/1/attributeConfigurations/" + ) alias = "projects/1/attributeConfigurations/" ac_test = AttributeConfigurationCollection(self.tamr, alias) responses.add(responses.GET, ac_url, json=self.acc_json) diff --git a/tests/unit/test_binning_model.py b/tests/unit/test_binning_model.py index 7fe111c2..7331ddf3 100644 --- a/tests/unit/test_binning_model.py +++ b/tests/unit/test_binning_model.py @@ -15,7 +15,7 @@ "externalId": "Project1", "resourceId": "1", } -project_url = f"http://localhost:9100/api/versioned/v1/projects/1" +project_url = "http://localhost:9100/api/versioned/v1/projects/1" @responses.activate @@ -34,7 +34,7 @@ def test_binning_model_records(): ] records_url = ( - f"http://localhost:9100/api/versioned/v1/projects/1/binningModel/records" + "http://localhost:9100/api/versioned/v1/projects/1/binningModel/records" ) responses.add(responses.GET, project_url, json=project_config) @@ -136,7 +136,7 @@ def update_callback(request, snoop): return 200, {}, "{}" update_records_url = ( - f"http://localhost:9100/api/versioned/v1/projects/1/binningModel/records" + "http://localhost:9100/api/versioned/v1/projects/1/binningModel/records" ) responses.add(responses.GET, project_url, json=project_config) diff --git a/tests/unit/test_categorization.py b/tests/unit/test_categorization.py index 6c60ef03..0ab5de68 100644 --- a/tests/unit/test_categorization.py +++ b/tests/unit/test_categorization.py @@ -13,8 +13,8 @@ def setUp(self): @responses.activate def test_taxonomy(self): - project_url = f"http://localhost:9100/api/versioned/v1/projects/1" - taxonomy_url = f"http://localhost:9100/api/versioned/v1/projects/1/taxonomy" + project_url = "http://localhost:9100/api/versioned/v1/projects/1" + taxonomy_url = "http://localhost:9100/api/versioned/v1/projects/1/taxonomy" responses.add(responses.GET, project_url, json=self._project_json) responses.add(responses.POST, taxonomy_url, json=self._taxonomy_json) diff --git a/tests/unit/test_create_dataset.py b/tests/unit/test_create_dataset.py index 16c0a7e0..ef0a267f 100644 --- a/tests/unit/test_create_dataset.py +++ b/tests/unit/test_create_dataset.py @@ -169,7 +169,7 @@ def create_callback(request, snoop): "externalId": "Dataset created with pubapi", } -_datasets_url = f"http://localhost:9100/api/versioned/v1/datasets" +_datasets_url = "http://localhost:9100/api/versioned/v1/datasets" _dataset_url = _datasets_url + "/1" _attribute_url = _dataset_url + "/attributes" _records_url = _dataset_url + ":updateRecords" diff --git a/tests/unit/test_create_project.py b/tests/unit/test_create_project.py index 8ad65f28..ab703140 100644 --- a/tests/unit/test_create_project.py +++ b/tests/unit/test_create_project.py @@ -34,7 +34,7 @@ "relativeId": "projects/1", } -projects_url = f"http://localhost:9100/api/versioned/v1/projects" +projects_url = "http://localhost:9100/api/versioned/v1/projects" project_url = f"{projects_url}/1" diff --git a/tests/unit/test_dataset_attributes.py b/tests/unit/test_dataset_attributes.py index 7da576f3..ffe8d297 100644 --- a/tests/unit/test_dataset_attributes.py +++ b/tests/unit/test_dataset_attributes.py @@ -16,7 +16,7 @@ def test_dataset_attributes(): "isNullable": "false", } - dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/1" + dataset_url = "http://localhost:9100/api/versioned/v1/datasets/1" responses.add(responses.GET, dataset_url, json={}) responses.add( diff --git a/tests/unit/test_dataset_geo.py b/tests/unit/test_dataset_geo.py index 8a9b2177..e399fc24 100644 --- a/tests/unit/test_dataset_geo.py +++ b/tests/unit/test_dataset_geo.py @@ -231,7 +231,7 @@ def key_value_composite(rec): @responses.activate def test_geo_features(self): - dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/1" + dataset_url = "http://localhost:9100/api/versioned/v1/datasets/1" responses.add(responses.GET, dataset_url, json=self._dataset_json) attributes_url = f"{dataset_url}/attributes" @@ -260,7 +260,7 @@ def test_geo_features(self): @responses.activate def test_geo_features_geo_attr(self): - dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/1" + dataset_url = "http://localhost:9100/api/versioned/v1/datasets/1" responses.add(responses.GET, dataset_url, json=self._dataset_json) # Create a dataset with multiple geometry attributes @@ -287,7 +287,7 @@ def test_geo_features_geo_attr(self): @responses.activate def test_geo_interface(self): - dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/1" + dataset_url = "http://localhost:9100/api/versioned/v1/datasets/1" responses.add(responses.GET, dataset_url, json=self._dataset_json) attributes_url = f"{dataset_url}/attributes" @@ -495,7 +495,7 @@ def update_callback(request, snoop): snoop["payload"] = request.body return 200, {}, "{}" - dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/1" + dataset_url = "http://localhost:9100/api/versioned/v1/datasets/1" responses.add(responses.GET, dataset_url, json=self._dataset_json) attributes_url = f"{dataset_url}/attributes" @@ -546,7 +546,7 @@ def update_callback(request, snoop): snoop["payload"] = request.body return 200, {}, "{}" - dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/1" + dataset_url = "http://localhost:9100/api/versioned/v1/datasets/1" responses.add(responses.GET, dataset_url, json=self._dataset_json) # Create a dataset with multiple geometry attributes @@ -599,7 +599,7 @@ def update_callback(request, snoop): composite_key_dataset_json = deepcopy(self._dataset_json) composite_key_dataset_json["keyAttributeNames"] = ["id1", "id2"] - dataset_url = f"http://localhost:9100/api/versioned/v1/datasets/1" + dataset_url = "http://localhost:9100/api/versioned/v1/datasets/1" responses.add(responses.GET, dataset_url, json=composite_key_dataset_json) composite_key_attributes_json = deepcopy(self._attributes_json) diff --git a/tests/unit/test_dataset_records.py b/tests/unit/test_dataset_records.py index 6484e007..c611269f 100644 --- a/tests/unit/test_dataset_records.py +++ b/tests/unit/test_dataset_records.py @@ -22,7 +22,7 @@ def test_get(self): responses.add( responses.GET, records_url, - body="\n".join([simplejson.dumps(l) for l in self._records_json]), + body="\n".join([simplejson.dumps(x) for x in self._records_json]), ) dataset = self.tamr.datasets.by_resource_id(self._dataset_id) diff --git a/tests/unit/test_dataset_usage.py b/tests/unit/test_dataset_usage.py index e89b4466..aa088532 100644 --- a/tests/unit/test_dataset_usage.py +++ b/tests/unit/test_dataset_usage.py @@ -71,7 +71,7 @@ def test_project_step(self): project = step.project() self.assertEqual(project.relative_id, self._projects_json[0]["relativeId"]) - _base_url = f"http://localhost:9100/api/versioned/v1" + _base_url = "http://localhost:9100/api/versioned/v1" _dataset_json = { "id": "unify://unified-data/v1/datasets/1", diff --git a/tests/unit/test_http_error.py b/tests/unit/test_http_error.py index 5ed13b7a..93ccdde2 100644 --- a/tests/unit/test_http_error.py +++ b/tests/unit/test_http_error.py @@ -10,7 +10,7 @@ def test_http_error(): """Ensure that the client surfaces HTTP errors as exceptions. """ - endpoint = f"http://localhost:9100/api/versioned/v1/projects/1" + endpoint = "http://localhost:9100/api/versioned/v1/projects/1" responses.add(responses.GET, endpoint, status=401) auth = UsernamePasswordAuth("nonexistent-username", "invalid-password") tamr = Client(auth) diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py index 92bb5a6a..4d8148c0 100644 --- a/tests/unit/test_project.py +++ b/tests/unit/test_project.py @@ -238,7 +238,7 @@ def create_callback(request, snoop): project_list_url = "http://localhost:9100/api/versioned/v1/projects" post_input_datasets_json = [] input_datasets_url = ( - f"http://localhost:9100/api/versioned/v1/projects/1/inputDatasets" + "http://localhost:9100/api/versioned/v1/projects/1/inputDatasets" ) get_input_datasets_json = dataset_json diff --git a/tests/unit/test_published_clusters_with_data.py b/tests/unit/test_published_clusters_with_data.py index c2f5abb3..a40d927f 100644 --- a/tests/unit/test_published_clusters_with_data.py +++ b/tests/unit/test_published_clusters_with_data.py @@ -63,7 +63,7 @@ def test_published_clusters_with_data(): unified_dataset_url = ( f"http://localhost:9100/api/versioned/v1/projects/{project_id}/unifiedDataset" ) - datasets_url = f"http://localhost:9100/api/versioned/v1/datasets" + datasets_url = "http://localhost:9100/api/versioned/v1/datasets" refresh_url = project_url + "/publishedClustersWithData:refresh" responses.add(responses.GET, project_url, json=project_config) diff --git a/tests/unit/test_record_clusters_with_data.py b/tests/unit/test_record_clusters_with_data.py index 46666703..d7824d13 100644 --- a/tests/unit/test_record_clusters_with_data.py +++ b/tests/unit/test_record_clusters_with_data.py @@ -64,7 +64,7 @@ def test_record_clusters_with_data(): unified_dataset_url = ( f"http://localhost:9100/api/versioned/v1/projects/{project_id}/unifiedDataset" ) - datasets_url = f"http://localhost:9100/api/versioned/v1/datasets" + datasets_url = "http://localhost:9100/api/versioned/v1/datasets" refresh_url = project_url + "/recordClustersWithData:refresh" responses.add(responses.GET, project_url, json=project_config) From 26d39c491fff3619272532c2442d9651566aa0f7 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Thu, 28 May 2020 18:25:43 -0400 Subject: [PATCH 391/632] Fix typo in contributor guide Co-authored-by: skalish <39866163+skalish@users.noreply.github.com> --- docs/contributor-guide/run-and-build.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contributor-guide/run-and-build.md b/docs/contributor-guide/run-and-build.md index 6e5812ff..6486e9c9 100644 --- a/docs/contributor-guide/run-and-build.md +++ b/docs/contributor-guide/run-and-build.md @@ -58,7 +58,7 @@ poetry run nox -s test # without alias --- -To run tests for a specifc Python version e.g. 3.6: +To run tests for a specific Python version e.g. 3.6: ```sh prn -s test-3.6 # with alias From 2c564818d5989c4311c12506d89737f544375d98 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 28 May 2020 12:46:47 -0400 Subject: [PATCH 392/632] Change imports to -from x import y- style in tamr_client. --- tamr_client/__init__.py | 20 ++++++++++---------- tamr_client/attributes/attribute.py | 5 ++--- tamr_client/dataset/dataset.py | 2 +- tamr_client/dataset/record.py | 2 +- tamr_client/project.py | 4 ++-- tamr_client/url.py | 2 +- 6 files changed, 17 insertions(+), 18 deletions(-) diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index fc001000..ecd12984 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -19,26 +19,26 @@ ################## # utilities -import tamr_client.response as response +from tamr_client import response # instance from tamr_client.instance import Instance -import tamr_client.instance as instance +from tamr_client import instance # url from tamr_client.url import URL -import tamr_client.url as url +from tamr_client import url # auth from tamr_client.auth import UsernamePasswordAuth # session from tamr_client.session import Session -import tamr_client.session as session +from tamr_client import session # datasets from tamr_client.dataset import Dataset, DatasetNotFound -import tamr_client.dataset as dataset +from tamr_client import dataset # records from tamr_client.dataset.record import PrimaryKeyNotFound @@ -50,10 +50,10 @@ # attributes from tamr_client.attributes.subattribute import SubAttribute -import tamr_client.attributes.subattribute as subattribute +from tamr_client.attributes import subattribute from tamr_client.attributes.attribute_type import AttributeType -import tamr_client.attributes.attribute_type as attribute_type +from tamr_client.attributes import attribute_type import tamr_client.attributes.type_alias @@ -63,7 +63,7 @@ AttributeExists, AttributeNotFound, ) -import tamr_client.attributes.attribute as attribute +from tamr_client.attributes import attribute -import tamr_client.mastering as mastering -import tamr_client.project as project +from tamr_client import mastering +from tamr_client import project diff --git a/tamr_client/attributes/attribute.py b/tamr_client/attributes/attribute.py index 9743c4d8..ab0cb924 100644 --- a/tamr_client/attributes/attribute.py +++ b/tamr_client/attributes/attribute.py @@ -5,11 +5,10 @@ from dataclasses import dataclass, field, replace from typing import Optional, Tuple -import tamr_client.attributes.attribute_type as attribute_type +from tamr_client import response +from tamr_client.attributes import attribute_type, type_alias from tamr_client.attributes.attribute_type import AttributeType -import tamr_client.attributes.type_alias as type_alias from tamr_client.dataset.dataset import Dataset -import tamr_client.response as response from tamr_client.session import Session from tamr_client.types import JsonDict from tamr_client.url import URL diff --git a/tamr_client/dataset/dataset.py b/tamr_client/dataset/dataset.py index 95fd7ad0..34ef0b32 100644 --- a/tamr_client/dataset/dataset.py +++ b/tamr_client/dataset/dataset.py @@ -5,8 +5,8 @@ from dataclasses import dataclass from typing import Optional, Tuple +from tamr_client import response from tamr_client.instance import Instance -import tamr_client.response as response from tamr_client.session import Session from tamr_client.types import JsonDict from tamr_client.url import URL diff --git a/tamr_client/dataset/record.py b/tamr_client/dataset/record.py index 61ffca84..514b82c7 100644 --- a/tamr_client/dataset/record.py +++ b/tamr_client/dataset/record.py @@ -7,8 +7,8 @@ import json from typing import cast, Dict, IO, Iterable, Optional +from tamr_client import response from tamr_client.dataset.dataset import Dataset -import tamr_client.response as response from tamr_client.session import Session from tamr_client.types import JsonDict diff --git a/tamr_client/project.py b/tamr_client/project.py index 7c9c0f20..15b6229c 100644 --- a/tamr_client/project.py +++ b/tamr_client/project.py @@ -1,9 +1,9 @@ from typing import Union +from tamr_client import response from tamr_client.instance import Instance -import tamr_client.mastering.project as mastering_project +from tamr_client.mastering import project as mastering_project from tamr_client.mastering.project import Project as MasteringProject -import tamr_client.response as response from tamr_client.session import Session from tamr_client.types import JsonDict from tamr_client.url import URL diff --git a/tamr_client/url.py b/tamr_client/url.py index 28d4cb52..428200ee 100644 --- a/tamr_client/url.py +++ b/tamr_client/url.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -import tamr_client.instance as instance +from tamr_client import instance from tamr_client.instance import Instance From 7c1411b315ccc6181945bcca792c0b5fbea28378 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 28 May 2020 12:49:20 -0400 Subject: [PATCH 393/632] Change imports to -from x import y- style in tests/tamr_client. --- tests/tamr_client/attributes/test_attribute.py | 2 +- tests/tamr_client/attributes/test_attribute_type.py | 2 +- tests/tamr_client/datasets/test_dataframe.py | 2 +- tests/tamr_client/datasets/test_dataset.py | 2 +- tests/tamr_client/datasets/test_record.py | 2 +- tests/tamr_client/test_project.py | 2 +- tests/tamr_client/test_response.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/tamr_client/attributes/test_attribute.py b/tests/tamr_client/attributes/test_attribute.py index e507eb00..8de37afd 100644 --- a/tests/tamr_client/attributes/test_attribute.py +++ b/tests/tamr_client/attributes/test_attribute.py @@ -4,7 +4,7 @@ import responses import tamr_client as tc -import tests.tamr_client.utils as utils +from tests.tamr_client import utils def test_from_json(): diff --git a/tests/tamr_client/attributes/test_attribute_type.py b/tests/tamr_client/attributes/test_attribute_type.py index 39a85322..6843c347 100644 --- a/tests/tamr_client/attributes/test_attribute_type.py +++ b/tests/tamr_client/attributes/test_attribute_type.py @@ -1,5 +1,5 @@ import tamr_client as tc -import tests.tamr_client.utils as utils +from tests.tamr_client import utils def test_from_json(): diff --git a/tests/tamr_client/datasets/test_dataframe.py b/tests/tamr_client/datasets/test_dataframe.py index 408c070d..62a5216d 100644 --- a/tests/tamr_client/datasets/test_dataframe.py +++ b/tests/tamr_client/datasets/test_dataframe.py @@ -6,7 +6,7 @@ import responses import tamr_client as tc -import tests.tamr_client.utils as utils +from tests.tamr_client import utils @responses.activate diff --git a/tests/tamr_client/datasets/test_dataset.py b/tests/tamr_client/datasets/test_dataset.py index 1fd0103d..b8413bc7 100644 --- a/tests/tamr_client/datasets/test_dataset.py +++ b/tests/tamr_client/datasets/test_dataset.py @@ -2,7 +2,7 @@ import responses import tamr_client as tc -import tests.tamr_client.utils as utils +from tests.tamr_client import utils @responses.activate diff --git a/tests/tamr_client/datasets/test_record.py b/tests/tamr_client/datasets/test_record.py index 18111310..784a25da 100644 --- a/tests/tamr_client/datasets/test_record.py +++ b/tests/tamr_client/datasets/test_record.py @@ -5,7 +5,7 @@ import responses import tamr_client as tc -import tests.tamr_client.utils as utils +from tests.tamr_client import utils @responses.activate diff --git a/tests/tamr_client/test_project.py b/tests/tamr_client/test_project.py index 10f96126..bfb78c9d 100644 --- a/tests/tamr_client/test_project.py +++ b/tests/tamr_client/test_project.py @@ -2,7 +2,7 @@ import responses import tamr_client as tc -import tests.tamr_client.utils as utils +from tests.tamr_client import utils @responses.activate diff --git a/tests/tamr_client/test_response.py b/tests/tamr_client/test_response.py index 14817e5c..84d9b8d8 100644 --- a/tests/tamr_client/test_response.py +++ b/tests/tamr_client/test_response.py @@ -3,7 +3,7 @@ import responses import tamr_client as tc -import tests.tamr_client.utils as utils +from tests.tamr_client import utils @responses.activate From 3481c6854ff4f7b63b09e3d4fef3786e97a17fde Mon Sep 17 00:00:00 2001 From: skalish Date: Mon, 1 Jun 2020 11:27:01 -0400 Subject: [PATCH 394/632] Add style guide page to contributor guide. --- docs/contributor-guide.md | 1 + docs/contributor-guide/style-guide.md | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 docs/contributor-guide/style-guide.md diff --git a/docs/contributor-guide.md b/docs/contributor-guide.md index 23e0b7df..a4f06faf 100644 --- a/docs/contributor-guide.md +++ b/docs/contributor-guide.md @@ -1,6 +1,7 @@ # Contributor guide * [Bug Reports and Feature Requests](contributor-guide/bugs-and-features) + * [Style Guide](contributor-guide/style-guide) * [Code Migrations](contributor-guide/migration) * [Configure your Text Editor](contributor-guide/config-text-editor) * [Install](contributor-guide/install) diff --git a/docs/contributor-guide/style-guide.md b/docs/contributor-guide/style-guide.md new file mode 100644 index 00000000..4fa235f2 --- /dev/null +++ b/docs/contributor-guide/style-guide.md @@ -0,0 +1,19 @@ +# Style Guide + +### Formatting +Code should generally conform to the [PEP8 style guidelines](https://www.python.org/dev/peps/pep-0008/). + * [Flake8](https://flake8.pycqa.org/en/latest/) is a linter to help check that code is aligned with these formatting requirements + * [Black](https://black.readthedocs.io/en/stable/) is a formatter that can be used to automatically reformat code to resolve many (but not all) formatting issues + * For details on using these tools [see here](run-and-build) + +### Structure +* Classes with methods should be avoided in favor of simple [dataclasses](https://docs.python.org/3/library/dataclasses.html) and functions + +### Google-style docstrings +All functions and class definitions should use [Google-style docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) and be annotated with [type hints](https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html#type-annotations). + +### Internal Imports +When importing from within tamr-client: +* Use import statements for modules, classes, and exceptions +* Never import functions directly. Instead import the containing module and use module.function +* Use from foo import bar instead of import foo.bar as bar From 25476b48b79801617a668ad1305e03607ed53ee0 Mon Sep 17 00:00:00 2001 From: skalish Date: Mon, 1 Jun 2020 13:36:38 -0400 Subject: [PATCH 395/632] Arrange contributor guide pages into alphabetical order. --- docs/contributor-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contributor-guide.md b/docs/contributor-guide.md index a4f06faf..91de4537 100644 --- a/docs/contributor-guide.md +++ b/docs/contributor-guide.md @@ -1,11 +1,11 @@ # Contributor guide * [Bug Reports and Feature Requests](contributor-guide/bugs-and-features) - * [Style Guide](contributor-guide/style-guide) * [Code Migrations](contributor-guide/migration) * [Configure your Text Editor](contributor-guide/config-text-editor) * [Install](contributor-guide/install) * [Navigating Inheritance](contributor-guide/navigating-inheritance) * [Pull Requests](contributor-guide/pull-request) * [Run and Build](contributor-guide/run-and-build) + * [Style Guide](contributor-guide/style-guide) * [Toolchain](contributor-guide/toolchain) From 99e1dad1c923119e20922b5b6cc15a392de1c40a Mon Sep 17 00:00:00 2001 From: skalish Date: Mon, 1 Jun 2020 13:38:01 -0400 Subject: [PATCH 396/632] Replace with back-ticks. --- docs/contributor-guide/style-guide.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/contributor-guide/style-guide.md b/docs/contributor-guide/style-guide.md index 4fa235f2..4d4b7185 100644 --- a/docs/contributor-guide/style-guide.md +++ b/docs/contributor-guide/style-guide.md @@ -13,7 +13,7 @@ Code should generally conform to the [PEP8 style guidelines](https://www.python. All functions and class definitions should use [Google-style docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) and be annotated with [type hints](https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html#type-annotations). ### Internal Imports -When importing from within tamr-client: +When importing from within `tamr-client`: * Use import statements for modules, classes, and exceptions -* Never import functions directly. Instead import the containing module and use module.function -* Use from foo import bar instead of import foo.bar as bar +* Never import functions directly. Instead import the containing module and use `module.function` +* Use `from foo import bar` instead of `import foo.bar as bar` From 4746de6f8285f03b844f84e0ad4e9ea0cc1810c0 Mon Sep 17 00:00:00 2001 From: skalish Date: Tue, 9 Jun 2020 16:26:28 -0400 Subject: [PATCH 397/632] Add function to stream records in dataset. --- tamr_client/dataset/record.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tamr_client/dataset/record.py b/tamr_client/dataset/record.py index 514b82c7..9bf34834 100644 --- a/tamr_client/dataset/record.py +++ b/tamr_client/dataset/record.py @@ -5,7 +5,7 @@ underlying _update function can be used directly." """ import json -from typing import cast, Dict, IO, Iterable, Optional +from typing import cast, Dict, Generator, IO, Iterable, Optional from tamr_client import response from tamr_client.dataset.dataset import Dataset @@ -143,3 +143,17 @@ def _delete_command(record: Dict, *, primary_key_name: str) -> Dict: The DELETE command in the proper format """ return {"action": "DELETE", "recordId": record[primary_key_name]} + + +def stream(session: Session, dataset: Dataset) -> Generator[Dict, None, None]: + """Stream the records in this dataset as Python dictionaries. + + Args: + dataset: Dataset from which to stream records + + Returns: + Python generator yielding records + """ + with session.get(str(dataset.url) + "/records", stream=True) as response: + for line in response.iter_lines(): + yield json.loads(line) From b2d5a23be537729464d5ca307db2e2826a0442fa Mon Sep 17 00:00:00 2001 From: skalish Date: Tue, 9 Jun 2020 16:48:13 -0400 Subject: [PATCH 398/632] Add first test of record streaming. --- tests/tamr_client/datasets/test_record.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/tamr_client/datasets/test_record.py b/tests/tamr_client/datasets/test_record.py index 784a25da..36c623d1 100644 --- a/tests/tamr_client/datasets/test_record.py +++ b/tests/tamr_client/datasets/test_record.py @@ -1,4 +1,5 @@ from functools import partial +import json from typing import Dict import pytest @@ -154,6 +155,20 @@ def test_delete_infer_primary_key(): assert snoop["payload"] == utils.stringify(deletes) +@responses.activate +def test_stream(): + s = utils.session() + dataset = utils.dataset() + + url = tc.URL(path="datasets/1/records") + responses.add( + responses.GET, str(url), body="\n".join([json.dumps(x) for x in _records_json]) + ) + + response = tc.record.stream(s, dataset) + assert list(response) == _records_json + + _records_json = [{"primary_key": 1}, {"primary_key": 2}] _response_json = { From de39242f2385afeee4c0b495ce44e34371fecd73 Mon Sep 17 00:00:00 2001 From: skalish Date: Tue, 9 Jun 2020 21:07:17 -0400 Subject: [PATCH 399/632] Fix typing and use ndjson-handling function. --- tamr_client/dataset/record.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tamr_client/dataset/record.py b/tamr_client/dataset/record.py index 9bf34834..e4ece30f 100644 --- a/tamr_client/dataset/record.py +++ b/tamr_client/dataset/record.py @@ -5,7 +5,7 @@ underlying _update function can be used directly." """ import json -from typing import cast, Dict, Generator, IO, Iterable, Optional +from typing import cast, Dict, IO, Iterable, Iterator, Optional from tamr_client import response from tamr_client.dataset.dataset import Dataset @@ -145,7 +145,7 @@ def _delete_command(record: Dict, *, primary_key_name: str) -> Dict: return {"action": "DELETE", "recordId": record[primary_key_name]} -def stream(session: Session, dataset: Dataset) -> Generator[Dict, None, None]: +def stream(session: Session, dataset: Dataset) -> Iterator[JsonDict]: """Stream the records in this dataset as Python dictionaries. Args: @@ -154,6 +154,5 @@ def stream(session: Session, dataset: Dataset) -> Generator[Dict, None, None]: Returns: Python generator yielding records """ - with session.get(str(dataset.url) + "/records", stream=True) as response: - for line in response.iter_lines(): - yield json.loads(line) + with session.get(str(dataset.url) + "/records", stream=True) as request: + return response.ndjson(request) From 60b961bb0ad194268857d1aac0ed4b7b0e6e3b60 Mon Sep 17 00:00:00 2001 From: skalish Date: Tue, 9 Jun 2020 21:08:07 -0400 Subject: [PATCH 400/632] Change list comprehension to generator and improve variable name in testing. --- tests/tamr_client/datasets/test_record.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/tamr_client/datasets/test_record.py b/tests/tamr_client/datasets/test_record.py index 36c623d1..5053bc08 100644 --- a/tests/tamr_client/datasets/test_record.py +++ b/tests/tamr_client/datasets/test_record.py @@ -162,11 +162,11 @@ def test_stream(): url = tc.URL(path="datasets/1/records") responses.add( - responses.GET, str(url), body="\n".join([json.dumps(x) for x in _records_json]) + responses.GET, str(url), body="\n".join(json.dumps(x) for x in _records_json) ) - response = tc.record.stream(s, dataset) - assert list(response) == _records_json + records = tc.record.stream(s, dataset) + assert list(records) == _records_json _records_json = [{"primary_key": 1}, {"primary_key": 2}] From 49b5e0e895dccfce5420fb11fda76c58f4fc768f Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 10 Jun 2020 09:00:04 -0400 Subject: [PATCH 401/632] Rename variable to avoid inaccuracy. --- tamr_client/dataset/record.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tamr_client/dataset/record.py b/tamr_client/dataset/record.py index e4ece30f..251896f3 100644 --- a/tamr_client/dataset/record.py +++ b/tamr_client/dataset/record.py @@ -154,5 +154,5 @@ def stream(session: Session, dataset: Dataset) -> Iterator[JsonDict]: Returns: Python generator yielding records """ - with session.get(str(dataset.url) + "/records", stream=True) as request: - return response.ndjson(request) + with session.get(str(dataset.url) + "/records", stream=True) as r: + return response.ndjson(r) From cb7f1189f02245f05adb661484ab5cada046c58d Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 10 Jun 2020 11:24:51 -0400 Subject: [PATCH 402/632] Update docs and CHANGELOG. --- CHANGELOG.md | 1 + docs/beta/dataset/record.rst | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae4a7ba6..ad94b6a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - [#367](https://github.com/Datatamer/tamr-client/issues/367) Support for projects: - generic projects via `tc.project` - Mastering projects via `tc.mastering.project` + - Support for streaming records from a dataset via `tc.record.stream` **BUG FIXES** - `from_geo_features` now returns information on the operation. diff --git a/docs/beta/dataset/record.rst b/docs/beta/dataset/record.rst index a34941a1..133bcf00 100644 --- a/docs/beta/dataset/record.rst +++ b/docs/beta/dataset/record.rst @@ -7,6 +7,7 @@ Record .. autofunction:: tamr_client.record.upsert .. autofunction:: tamr_client.record.delete .. autofunction:: tamr_client.record._update +.. autofunction:: tamr_client.record.stream Exceptions ---------- From 4ff589b480704adc97614546b50e78106ad22298 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 10 Jun 2020 13:18:50 -0400 Subject: [PATCH 403/632] Update wording and function reference in record.py module docstring. --- tamr_client/dataset/record.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tamr_client/dataset/record.py b/tamr_client/dataset/record.py index 251896f3..86d679b3 100644 --- a/tamr_client/dataset/record.py +++ b/tamr_client/dataset/record.py @@ -1,8 +1,8 @@ """ See https://docs.tamr.com/reference/record -"The recommended approach for interacting with records is to use the :func:`~tamr_client.record.upsert` and +"The recommended approach for modifying records is to use the :func:`~tamr_client.record.upsert` and :func:`~tamr_client.record.delete` functions for all use cases they can handle. For more advanced use cases, the -underlying _update function can be used directly." +underlying :func:`~tamr_client.record._update` function can be used directly." """ import json from typing import cast, Dict, IO, Iterable, Iterator, Optional From 263e36134bf0d86ae36041da9d20d6b1e3caac54 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 10 Jun 2020 13:20:15 -0400 Subject: [PATCH 404/632] Rename tests/tamr_client/datasets to conform with naming in package. --- tests/tamr_client/{datasets => dataset}/test_dataframe.py | 0 tests/tamr_client/{datasets => dataset}/test_dataset.py | 0 tests/tamr_client/{datasets => dataset}/test_record.py | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename tests/tamr_client/{datasets => dataset}/test_dataframe.py (100%) rename tests/tamr_client/{datasets => dataset}/test_dataset.py (100%) rename tests/tamr_client/{datasets => dataset}/test_record.py (100%) diff --git a/tests/tamr_client/datasets/test_dataframe.py b/tests/tamr_client/dataset/test_dataframe.py similarity index 100% rename from tests/tamr_client/datasets/test_dataframe.py rename to tests/tamr_client/dataset/test_dataframe.py diff --git a/tests/tamr_client/datasets/test_dataset.py b/tests/tamr_client/dataset/test_dataset.py similarity index 100% rename from tests/tamr_client/datasets/test_dataset.py rename to tests/tamr_client/dataset/test_dataset.py diff --git a/tests/tamr_client/datasets/test_record.py b/tests/tamr_client/dataset/test_record.py similarity index 100% rename from tests/tamr_client/datasets/test_record.py rename to tests/tamr_client/dataset/test_record.py From 3654817526982931f7eb8d3ddf57b957f9a4d819 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Thu, 11 Jun 2020 12:12:24 -0400 Subject: [PATCH 405/632] Began making unified dataset class. --- tamr_client/dataset/dataset.py | 8 +++++--- tamr_client/dataset/unified.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 tamr_client/dataset/unified.py diff --git a/tamr_client/dataset/dataset.py b/tamr_client/dataset/dataset.py index 34ef0b32..b8064845 100644 --- a/tamr_client/dataset/dataset.py +++ b/tamr_client/dataset/dataset.py @@ -3,20 +3,19 @@ """ from copy import deepcopy from dataclasses import dataclass -from typing import Optional, Tuple +from typing import Optional, Tuple, Union from tamr_client import response from tamr_client.instance import Instance from tamr_client.session import Session from tamr_client.types import JsonDict from tamr_client.url import URL - +from tamr_client.dataset.unified import UnifiedDataset class DatasetNotFound(Exception): """Raised when referencing (e.g. updating or deleting) a dataset that does not exist on the server. """ - pass @@ -89,3 +88,6 @@ def _from_json(url: URL, data: JsonDict) -> Dataset: description=cp.get("description"), key_attribute_names=tuple(cp["keyAttributeNames"]), ) + + +AllDataset = Union[Dataset, UnifiedDataset] \ No newline at end of file diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py new file mode 100644 index 00000000..7cb1904d --- /dev/null +++ b/tamr_client/dataset/unified.py @@ -0,0 +1,28 @@ +""" +See https://docs.tamr.com/reference/dataset-models +""" +from copy import deepcopy +from dataclasses import dataclass +from typing import Optional, Tuple +from tamr_client import response +from tamr_client.instance import Instance +from tamr_client.session import Session +from tamr_client.types import JsonDict +from tamr_client.url import URL +@dataclass(frozen=True) +class UnifiedDataset: + """A Tamr dataset + + See https://docs.tamr.com/reference/dataset-models + + Args: + url + key_attribute_names + """ + + url: URL + name: str + key_attribute_names: Tuple[str, ...] + description: Optional[str] = None + +def commit(unified_dataset: UnifiedDataset, session: Session, instance: Instance) -> JsonDict: From dc8f6c59be8163e17810704fd6743e855f1f8b19 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Fri, 12 Jun 2020 20:18:59 -0400 Subject: [PATCH 406/632] Added unified dataset object to the beta. added appropriate tests. --- tamr_client/__init__.py | 5 +- tamr_client/dataset/__init__.py | 4 +- tamr_client/dataset/dataset.py | 10 +-- tamr_client/dataset/unified.py | 81 ++++++++++++++++++- tests/tamr_client/data/operation.json | 22 +++++ .../dataset/test_unified_dataset.py | 55 +++++++++++++ tests/tamr_client/utils.py | 12 +++ 7 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 tests/tamr_client/data/operation.json create mode 100644 tests/tamr_client/dataset/test_unified_dataset.py diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index ecd12984..d1ffbe18 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -37,7 +37,7 @@ from tamr_client import session # datasets -from tamr_client.dataset import Dataset, DatasetNotFound +from tamr_client.dataset import AllDataset, Dataset, DatasetNotFound from tamr_client import dataset # records @@ -48,6 +48,9 @@ from tamr_client.dataset.dataframe import AmbiguousPrimaryKey from tamr_client.dataset import dataframe +# unified +from tamr_client.dataset import unified + # attributes from tamr_client.attributes.subattribute import SubAttribute from tamr_client.attributes import subattribute diff --git a/tamr_client/dataset/__init__.py b/tamr_client/dataset/__init__.py index 7f5a4cc6..70d89760 100644 --- a/tamr_client/dataset/__init__.py +++ b/tamr_client/dataset/__init__.py @@ -1,5 +1,5 @@ # flake8: noqa -from tamr_client.dataset.dataset import Dataset +from tamr_client.dataset.dataset import AllDataset, Dataset from tamr_client.dataset.dataset import from_resource_id from tamr_client.dataset.dataset import DatasetNotFound -from tamr_client.dataset import dataframe, record +from tamr_client.dataset import dataframe, record, unified diff --git a/tamr_client/dataset/dataset.py b/tamr_client/dataset/dataset.py index b8064845..80af59aa 100644 --- a/tamr_client/dataset/dataset.py +++ b/tamr_client/dataset/dataset.py @@ -10,7 +10,8 @@ from tamr_client.session import Session from tamr_client.types import JsonDict from tamr_client.url import URL -from tamr_client.dataset.unified import UnifiedDataset +from tamr_client.dataset.unified import Dataset as UnifiedDataset + class DatasetNotFound(Exception): """Raised when referencing (e.g. updating or deleting) a dataset @@ -29,13 +30,15 @@ class Dataset: url key_attribute_names """ - url: URL name: str key_attribute_names: Tuple[str, ...] description: Optional[str] = None +AllDataset = Union[Dataset, UnifiedDataset] + + def from_resource_id(session: Session, instance: Instance, id: str) -> Dataset: """Get dataset by resource ID @@ -88,6 +91,3 @@ def _from_json(url: URL, data: JsonDict) -> Dataset: description=cp.get("description"), key_attribute_names=tuple(cp["keyAttributeNames"]), ) - - -AllDataset = Union[Dataset, UnifiedDataset] \ No newline at end of file diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index 7cb1904d..0277717f 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -9,8 +9,18 @@ from tamr_client.session import Session from tamr_client.types import JsonDict from tamr_client.url import URL +from tamr_client.project import Project + + +class DatasetNotFound(Exception): + """Raised when referencing (e.g. updating or deleting) a dataset + that does not exist on the server. + """ + pass + + @dataclass(frozen=True) -class UnifiedDataset: +class Dataset: """A Tamr dataset See https://docs.tamr.com/reference/dataset-models @@ -25,4 +35,71 @@ class UnifiedDataset: key_attribute_names: Tuple[str, ...] description: Optional[str] = None -def commit(unified_dataset: UnifiedDataset, session: Session, instance: Instance) -> JsonDict: + +def from_project(session: Session, instance: Instance, project: Project) -> Dataset: + """Get unified dataset of a project + + Fetches dataset from Tamr server + + Args: + instance: Tamr instance containing this dataset + project: Tamr project of this Unified Dataset + + Raises: + UnifiedDatasetNotFound: If no unified dataset could be found at the specified URL. + Corresponds to a 404 HTTP error. + requests.HTTPError: If any other HTTP error is encountered. + """ + url = URL(instance=instance, path=f"{project.url.path}/unifiedDataset") + return _from_url(session, url) + + +def _from_url(session: Session, url: URL) -> Dataset: + """Get dataset by URL + + Fetches dataset from Tamr server + + Args: + url: Dataset URL + + Raises: + DatasetNotFound: If no dataset could be found at the specified URL. + Corresponds to a 404 HTTP error. + requests.HTTPError: If any other HTTP error is encountered. + """ + r = session.get(str(url)) + if r.status_code == 404: + raise DatasetNotFound(str(url)) + data = response.successful(r).json() + return _from_json(url, data) + + +def _from_json(url: URL, data: JsonDict) -> Dataset: + """Make unified dataset from JSON data (deserialize) + + Args: + url: Unified Dataset URL + data: Unified Dataset JSON data from Tamr server + """ + cp = deepcopy(data) + return Dataset( + url, + name=cp["name"], + description=cp.get("description"), + key_attribute_names=tuple(cp["keyAttributeNames"]), + ) + + +def commit(unified_dataset: Dataset, session: Session) -> JsonDict: + """Commits the Unified Dataset. + + Args: + unified_dataset: The UnifiedDataset which will be committed + session: The Tamr Session + """ + r = session.post( + str(unified_dataset.url) + ":refresh", + headers={"Content-Type": "application/json", + "Accept": "application/json"} + ) + return response.successful(r).json() \ No newline at end of file diff --git a/tests/tamr_client/data/operation.json b/tests/tamr_client/data/operation.json new file mode 100644 index 00000000..c2169d4e --- /dev/null +++ b/tests/tamr_client/data/operation.json @@ -0,0 +1,22 @@ +{ + "id": "1", + "type": "SPARK", + "description": "operation 1 description", + "status": { + "state": "PENDING", + "startTime": "", + "endTime": "", + "message": "Job has not yet been submitted to Spark" + }, + "created": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 modified version" + }, + "relativeId": "operations/1" +} \ No newline at end of file diff --git a/tests/tamr_client/dataset/test_unified_dataset.py b/tests/tamr_client/dataset/test_unified_dataset.py new file mode 100644 index 00000000..5ea41d87 --- /dev/null +++ b/tests/tamr_client/dataset/test_unified_dataset.py @@ -0,0 +1,55 @@ +from functools import partial +import pytest +from typing import Dict +import responses + +import tamr_client as tc +from tests.tamr_client import utils + + +@responses.activate +def test_from_project(): + s = utils.session() + instance = utils.instance() + project = utils.mastering_project() + + dataset_json = utils.load_json("dataset.json") + url = tc.URL(path="projects/1/unifiedDataset") + responses.add(responses.GET, str(url), json=dataset_json) + + unified_dataset = tc.dataset.unified.from_project(s, instance, project) + assert unified_dataset.name == "dataset 1 name" + assert unified_dataset.description == "dataset 1 description" + assert unified_dataset.key_attribute_names == ("tamr_id",) + + +@responses.activate +def test_from_project_dataset_not_found(): + s = utils.session() + instance = utils.instance() + project = utils.mastering_project() + + url = tc.URL(path="projects/1/unifiedDataset") + responses.add(responses.GET, str(url), status=404) + + with pytest.raises(tc.unified.DatasetNotFound): + tc.unified.from_project(s, instance, project) + + +@responses.activate +def test_commit(): + s = utils.session() + instance = utils.instance() + project = utils.mastering_project() + + operation_json = utils.load_json("operation.json") + dataset_json = utils.load_json("dataset.json") + prj_url = tc.URL(path="projects/1/unifiedDataset") + responses.add(responses.GET, str(prj_url), json=dataset_json) + unified_dataset = tc.dataset.unified.from_project(s, instance, project) + + url = tc.URL(path="projects/1/unifiedDataset:refresh") + responses.add(responses.POST, str(url), json=operation_json) + + response = tc.unified.commit(unified_dataset,s) + assert response == operation_json diff --git a/tests/tamr_client/utils.py b/tests/tamr_client/utils.py index acaa6f5a..d7ef694c 100644 --- a/tests/tamr_client/utils.py +++ b/tests/tamr_client/utils.py @@ -28,6 +28,18 @@ def dataset(): return dataset +def unified_dataset(): + url = tc.URL(path="projects/1/unifiedDataset") + unified_dataset = tc.unified.Dataset(url, name="dataset.csv", key_attribute_names=("primary_key",)) + return unified_dataset + + +def mastering_project(): + url = tc.URL(path="projects/1") + mastering_project = tc.mastering.Project(url, name="Project 1", description="A Mastering Project") + return mastering_project + + def capture_payload(request, snoop, status, response_json): """Capture request body within `snoop` so we can inspect that the request body is constructed correctly (e.g. for streaming requests). From bda9a40a73c2191a8dcb54748bfb6fea0702730d Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Fri, 12 Jun 2020 20:37:05 -0400 Subject: [PATCH 407/632] Tried to satisfy the linter. --- tamr_client/dataset/dataset.py | 2 +- tamr_client/dataset/unified.py | 3 ++- tests/tamr_client/dataset/test_unified_dataset.py | 4 +--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/tamr_client/dataset/dataset.py b/tamr_client/dataset/dataset.py index 80af59aa..a86df421 100644 --- a/tamr_client/dataset/dataset.py +++ b/tamr_client/dataset/dataset.py @@ -9,8 +9,8 @@ from tamr_client.instance import Instance from tamr_client.session import Session from tamr_client.types import JsonDict -from tamr_client.url import URL from tamr_client.dataset.unified import Dataset as UnifiedDataset +from tamr_client.url import URL class DatasetNotFound(Exception): diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index 0277717f..40db7bfe 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -4,12 +4,13 @@ from copy import deepcopy from dataclasses import dataclass from typing import Optional, Tuple + from tamr_client import response from tamr_client.instance import Instance from tamr_client.session import Session from tamr_client.types import JsonDict -from tamr_client.url import URL from tamr_client.project import Project +from tamr_client.url import URL class DatasetNotFound(Exception): diff --git a/tests/tamr_client/dataset/test_unified_dataset.py b/tests/tamr_client/dataset/test_unified_dataset.py index 5ea41d87..869b5c04 100644 --- a/tests/tamr_client/dataset/test_unified_dataset.py +++ b/tests/tamr_client/dataset/test_unified_dataset.py @@ -1,6 +1,4 @@ -from functools import partial import pytest -from typing import Dict import responses import tamr_client as tc @@ -51,5 +49,5 @@ def test_commit(): url = tc.URL(path="projects/1/unifiedDataset:refresh") responses.add(responses.POST, str(url), json=operation_json) - response = tc.unified.commit(unified_dataset,s) + response = tc.unified.commit(unified_dataset, s) assert response == operation_json From 39a419f7a979e06514db4d0853645e4a72eaefba Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Fri, 12 Jun 2020 21:06:38 -0400 Subject: [PATCH 408/632] linter again --- tamr_client/dataset/dataset.py | 2 +- tamr_client/dataset/unified.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tamr_client/dataset/dataset.py b/tamr_client/dataset/dataset.py index a86df421..d3df0cbc 100644 --- a/tamr_client/dataset/dataset.py +++ b/tamr_client/dataset/dataset.py @@ -6,10 +6,10 @@ from typing import Optional, Tuple, Union from tamr_client import response +from tamr_client.dataset.unified import Dataset as UnifiedDataset from tamr_client.instance import Instance from tamr_client.session import Session from tamr_client.types import JsonDict -from tamr_client.dataset.unified import Dataset as UnifiedDataset from tamr_client.url import URL diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index 40db7bfe..e82d4a52 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -7,9 +7,9 @@ from tamr_client import response from tamr_client.instance import Instance +from tamr_client.project import Project from tamr_client.session import Session from tamr_client.types import JsonDict -from tamr_client.project import Project from tamr_client.url import URL @@ -103,4 +103,4 @@ def commit(unified_dataset: Dataset, session: Session) -> JsonDict: headers={"Content-Type": "application/json", "Accept": "application/json"} ) - return response.successful(r).json() \ No newline at end of file + return response.successful(r).json() From fbe4b6ee7eb8041614f825e7c09c0219d953adf2 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Mon, 15 Jun 2020 10:50:30 -0400 Subject: [PATCH 409/632] Formatter is satisfied. --- tamr_client/dataset/dataset.py | 2 ++ tamr_client/dataset/unified.py | 4 ++-- tests/tamr_client/utils.py | 8 ++++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/tamr_client/dataset/dataset.py b/tamr_client/dataset/dataset.py index d3df0cbc..1f73dff2 100644 --- a/tamr_client/dataset/dataset.py +++ b/tamr_client/dataset/dataset.py @@ -17,6 +17,7 @@ class DatasetNotFound(Exception): """Raised when referencing (e.g. updating or deleting) a dataset that does not exist on the server. """ + pass @@ -30,6 +31,7 @@ class Dataset: url key_attribute_names """ + url: URL name: str key_attribute_names: Tuple[str, ...] diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index e82d4a52..4cb9b59e 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -17,6 +17,7 @@ class DatasetNotFound(Exception): """Raised when referencing (e.g. updating or deleting) a dataset that does not exist on the server. """ + pass @@ -100,7 +101,6 @@ def commit(unified_dataset: Dataset, session: Session) -> JsonDict: """ r = session.post( str(unified_dataset.url) + ":refresh", - headers={"Content-Type": "application/json", - "Accept": "application/json"} + headers={"Content-Type": "application/json", "Accept": "application/json"}, ) return response.successful(r).json() diff --git a/tests/tamr_client/utils.py b/tests/tamr_client/utils.py index d7ef694c..30534583 100644 --- a/tests/tamr_client/utils.py +++ b/tests/tamr_client/utils.py @@ -30,13 +30,17 @@ def dataset(): def unified_dataset(): url = tc.URL(path="projects/1/unifiedDataset") - unified_dataset = tc.unified.Dataset(url, name="dataset.csv", key_attribute_names=("primary_key",)) + unified_dataset = tc.unified.Dataset( + url, name="dataset.csv", key_attribute_names=("primary_key",) + ) return unified_dataset def mastering_project(): url = tc.URL(path="projects/1") - mastering_project = tc.mastering.Project(url, name="Project 1", description="A Mastering Project") + mastering_project = tc.mastering.Project( + url, name="Project 1", description="A Mastering Project" + ) return mastering_project From 38dd35e298d92effa579cec44c9d30dc9f5adb6b Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Mon, 15 Jun 2020 11:14:34 -0400 Subject: [PATCH 410/632] Modified the noxfile.py so the formatting command: `prn -s format -- --fix` works --- noxfile.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/noxfile.py b/noxfile.py index 9d2578ad..e9400988 100644 --- a/noxfile.py +++ b/noxfile.py @@ -20,8 +20,10 @@ def lint(session): @nox.session(python="3.6") def format(session): session.run("poetry", "install", external=True) - check = "" if "--fix" in session.posargs else "--check" - session.run("black", check, ".") + if "--fix" in session.posargs: + session.run("black", ".") + else: + session.run("black", ".", "--check") @nox.session(python="3.6") From 715325919b66ac99846362f2420d929c16da96af Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Mon, 15 Jun 2020 12:01:51 -0400 Subject: [PATCH 411/632] AnyDataset instead of AllDataset Co-authored-by: Pedro Cattori --- tamr_client/dataset/dataset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_client/dataset/dataset.py b/tamr_client/dataset/dataset.py index 1f73dff2..bb251c32 100644 --- a/tamr_client/dataset/dataset.py +++ b/tamr_client/dataset/dataset.py @@ -38,7 +38,7 @@ class Dataset: description: Optional[str] = None -AllDataset = Union[Dataset, UnifiedDataset] +AnyDataset = Union[Dataset, UnifiedDataset] def from_resource_id(session: Session, instance: Instance, id: str) -> Dataset: From 3cde4bea59c59aab99e5e023777b62b5a8413afe Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Mon, 15 Jun 2020 12:02:33 -0400 Subject: [PATCH 412/632] session before unified_dataset object in function "commit" Co-authored-by: Pedro Cattori --- tamr_client/dataset/unified.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index 4cb9b59e..b34d1bce 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -92,7 +92,7 @@ def _from_json(url: URL, data: JsonDict) -> Dataset: ) -def commit(unified_dataset: Dataset, session: Session) -> JsonDict: +def commit(session: Session, unified_dataset: Dataset) -> JsonDict: """Commits the Unified Dataset. Args: From 58457dbd5b919b8ebec177368d2876813c84c4b9 Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Mon, 15 Jun 2020 12:02:54 -0400 Subject: [PATCH 413/632] Update tamr_client/dataset/unified.py Co-authored-by: Pedro Cattori --- tamr_client/dataset/unified.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index b34d1bce..535523dc 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -13,7 +13,7 @@ from tamr_client.url import URL -class DatasetNotFound(Exception): +class NotFound(Exception): """Raised when referencing (e.g. updating or deleting) a dataset that does not exist on the server. """ From 8fb84f81351477c160f2088b1c2428fcb54ad7b3 Mon Sep 17 00:00:00 2001 From: ianbakst <38843235+ianbakst@users.noreply.github.com> Date: Mon, 15 Jun 2020 12:04:32 -0400 Subject: [PATCH 414/632] AnyDataset instead of AllDataset --- tamr_client/dataset/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_client/dataset/__init__.py b/tamr_client/dataset/__init__.py index 70d89760..776fcd3c 100644 --- a/tamr_client/dataset/__init__.py +++ b/tamr_client/dataset/__init__.py @@ -1,5 +1,5 @@ # flake8: noqa -from tamr_client.dataset.dataset import AllDataset, Dataset +from tamr_client.dataset.dataset import AnyDataset, Dataset from tamr_client.dataset.dataset import from_resource_id from tamr_client.dataset.dataset import DatasetNotFound from tamr_client.dataset import dataframe, record, unified From 6d79343605f9fa7247040fb7d13956bab86ac34d Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Mon, 15 Jun 2020 12:44:22 -0400 Subject: [PATCH 415/632] dataset and unified have "NotFound" exceptions and the exceptions aren't hoisted to top-level. unified isn't hoisted beyond dataset/__init__.py --- docs/beta/dataset/dataset.rst | 2 +- tamr_client/__init__.py | 5 +---- tamr_client/dataset/__init__.py | 2 +- tamr_client/dataset/dataset.py | 8 ++++---- tamr_client/dataset/unified.py | 4 ++-- tamr_client/project.py | 2 +- tests/tamr_client/dataset/test_dataset.py | 2 +- tests/tamr_client/dataset/test_unified_dataset.py | 6 +++--- 8 files changed, 14 insertions(+), 17 deletions(-) diff --git a/docs/beta/dataset/dataset.rst b/docs/beta/dataset/dataset.rst index 62724246..e219c3ba 100644 --- a/docs/beta/dataset/dataset.rst +++ b/docs/beta/dataset/dataset.rst @@ -8,5 +8,5 @@ Dataset Exceptions ---------- -.. autoclass:: tamr_client.DatasetNotFound +.. autoclass:: tamr_client.dataset.NotFound :no-inherited-members: diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index d1ffbe18..15f25d2f 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -37,7 +37,7 @@ from tamr_client import session # datasets -from tamr_client.dataset import AllDataset, Dataset, DatasetNotFound +from tamr_client.dataset import AnyDataset, Dataset from tamr_client import dataset # records @@ -48,9 +48,6 @@ from tamr_client.dataset.dataframe import AmbiguousPrimaryKey from tamr_client.dataset import dataframe -# unified -from tamr_client.dataset import unified - # attributes from tamr_client.attributes.subattribute import SubAttribute from tamr_client.attributes import subattribute diff --git a/tamr_client/dataset/__init__.py b/tamr_client/dataset/__init__.py index 776fcd3c..f12f0821 100644 --- a/tamr_client/dataset/__init__.py +++ b/tamr_client/dataset/__init__.py @@ -1,5 +1,5 @@ # flake8: noqa from tamr_client.dataset.dataset import AnyDataset, Dataset from tamr_client.dataset.dataset import from_resource_id -from tamr_client.dataset.dataset import DatasetNotFound +from tamr_client.dataset.dataset import NotFound from tamr_client.dataset import dataframe, record, unified diff --git a/tamr_client/dataset/dataset.py b/tamr_client/dataset/dataset.py index bb251c32..c30154fb 100644 --- a/tamr_client/dataset/dataset.py +++ b/tamr_client/dataset/dataset.py @@ -13,7 +13,7 @@ from tamr_client.url import URL -class DatasetNotFound(Exception): +class NotFound(Exception): """Raised when referencing (e.g. updating or deleting) a dataset that does not exist on the server. """ @@ -51,7 +51,7 @@ def from_resource_id(session: Session, instance: Instance, id: str) -> Dataset: id: Dataset ID Raises: - DatasetNotFound: If no dataset could be found at the specified URL. + dataset.NotFound: If no dataset could be found at the specified URL. Corresponds to a 404 HTTP error. requests.HTTPError: If any other HTTP error is encountered. """ @@ -68,13 +68,13 @@ def _from_url(session: Session, url: URL) -> Dataset: url: Dataset URL Raises: - DatasetNotFound: If no dataset could be found at the specified URL. + dataset.NotFound: If no dataset could be found at the specified URL. Corresponds to a 404 HTTP error. requests.HTTPError: If any other HTTP error is encountered. """ r = session.get(str(url)) if r.status_code == 404: - raise DatasetNotFound(str(url)) + raise NotFound(str(url)) data = response.successful(r).json() return _from_json(url, data) diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index 535523dc..30306344 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -65,13 +65,13 @@ def _from_url(session: Session, url: URL) -> Dataset: url: Dataset URL Raises: - DatasetNotFound: If no dataset could be found at the specified URL. + unified.NotFound: If no dataset could be found at the specified URL. Corresponds to a 404 HTTP error. requests.HTTPError: If any other HTTP error is encountered. """ r = session.get(str(url)) if r.status_code == 404: - raise DatasetNotFound(str(url)) + raise NotFound(str(url)) data = response.successful(r).json() return _from_json(url, data) diff --git a/tamr_client/project.py b/tamr_client/project.py index 15b6229c..f4faabbf 100644 --- a/tamr_client/project.py +++ b/tamr_client/project.py @@ -29,7 +29,7 @@ def from_resource_id(session: Session, instance: Instance, id: str) -> Project: id: Project ID Raises: - NotFound: If no project could be found at the specified URL. + project.NotFound: If no project could be found at the specified URL. Corresponds to a 404 HTTP error. requests.HTTPError: If any other HTTP error is encountered. """ diff --git a/tests/tamr_client/dataset/test_dataset.py b/tests/tamr_client/dataset/test_dataset.py index b8413bc7..7a494876 100644 --- a/tests/tamr_client/dataset/test_dataset.py +++ b/tests/tamr_client/dataset/test_dataset.py @@ -28,5 +28,5 @@ def test_from_resource_id_dataset_not_found(): url = tc.URL(path="datasets/1") responses.add(responses.GET, str(url), status=404) - with pytest.raises(tc.DatasetNotFound): + with pytest.raises(tc.dataset.NotFound): tc.dataset.from_resource_id(s, instance, "1") diff --git a/tests/tamr_client/dataset/test_unified_dataset.py b/tests/tamr_client/dataset/test_unified_dataset.py index 869b5c04..3c56ff2a 100644 --- a/tests/tamr_client/dataset/test_unified_dataset.py +++ b/tests/tamr_client/dataset/test_unified_dataset.py @@ -30,8 +30,8 @@ def test_from_project_dataset_not_found(): url = tc.URL(path="projects/1/unifiedDataset") responses.add(responses.GET, str(url), status=404) - with pytest.raises(tc.unified.DatasetNotFound): - tc.unified.from_project(s, instance, project) + with pytest.raises(tc.dataset.unified.NotFound): + tc.dataset.unified.from_project(s, instance, project) @responses.activate @@ -49,5 +49,5 @@ def test_commit(): url = tc.URL(path="projects/1/unifiedDataset:refresh") responses.add(responses.POST, str(url), json=operation_json) - response = tc.unified.commit(unified_dataset, s) + response = tc.dataset.unified.commit(s, unified_dataset) assert response == operation_json From 048c6c2e7a728ff5c1f4ecb273417bec186b491e Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Mon, 15 Jun 2020 12:50:19 -0400 Subject: [PATCH 416/632] Fixed a module-calling issue in a test file. --- tests/tamr_client/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tamr_client/utils.py b/tests/tamr_client/utils.py index 30534583..9154f5a4 100644 --- a/tests/tamr_client/utils.py +++ b/tests/tamr_client/utils.py @@ -30,7 +30,7 @@ def dataset(): def unified_dataset(): url = tc.URL(path="projects/1/unifiedDataset") - unified_dataset = tc.unified.Dataset( + unified_dataset = tc.dataset.unified.Dataset( url, name="dataset.csv", key_attribute_names=("primary_key",) ) return unified_dataset From e55b98565b84620336dd2e155940d5be1d77e48e Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Mon, 15 Jun 2020 13:10:16 -0400 Subject: [PATCH 417/632] Update Docs to include Unified module --- docs/beta/dataset.md | 1 + docs/beta/dataset/unified.rst | 13 +++++++++++++ tamr_client/dataset/unified.py | 8 ++++---- 3 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 docs/beta/dataset/unified.rst diff --git a/docs/beta/dataset.md b/docs/beta/dataset.md index c5ebb45e..e95a0681 100644 --- a/docs/beta/dataset.md +++ b/docs/beta/dataset.md @@ -3,3 +3,4 @@ * [Dataset](/beta/dataset/dataset) * [Record](/beta/dataset/record) * [Dataframe](/beta/dataset/dataframe) + * [Unified](/beta/dataset/unified) diff --git a/docs/beta/dataset/unified.rst b/docs/beta/dataset/unified.rst new file mode 100644 index 00000000..4e3f62cd --- /dev/null +++ b/docs/beta/dataset/unified.rst @@ -0,0 +1,13 @@ +Unified +======= + +.. autoclass:: tamr_client.dataset.unified.Dataset + +.. autofunction:: tamr_client.dataset.unified.from_project +.. autofunction:: tamr_client.dataset.unified.commit + +Exceptions +---------- + +.. autoclass:: tamr_client.dataset.unified.NotFound + :no-inherited-members: diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index 30306344..f9c158b8 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -14,7 +14,7 @@ class NotFound(Exception): - """Raised when referencing (e.g. updating or deleting) a dataset + """Raised when referencing (e.g. updating or deleting) a unified dataset that does not exist on the server. """ @@ -23,7 +23,7 @@ class NotFound(Exception): @dataclass(frozen=True) class Dataset: - """A Tamr dataset + """A Tamr unified dataset See https://docs.tamr.com/reference/dataset-models @@ -41,14 +41,14 @@ class Dataset: def from_project(session: Session, instance: Instance, project: Project) -> Dataset: """Get unified dataset of a project - Fetches dataset from Tamr server + Fetches the unified dataset of a given project from Tamr server Args: instance: Tamr instance containing this dataset project: Tamr project of this Unified Dataset Raises: - UnifiedDatasetNotFound: If no unified dataset could be found at the specified URL. + unified.NotFound: If no unified dataset could be found at the specified URL. Corresponds to a 404 HTTP error. requests.HTTPError: If any other HTTP error is encountered. """ From 63ca3125fb8c6905d5b14f38de938a413129caa5 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Mon, 15 Jun 2020 13:15:19 -0400 Subject: [PATCH 418/632] added to changelog --- CHANGELOG.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad94b6a1..dde1263f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,13 @@ ## 0.12.0-dev **BETA** - Important: Do not use BETA features for production workflows. - + - [#372](https://github.com/Datatamer/tamr-client/issues/372) TC:Design for unified datasets + - `AnyDataset` can be any type of dataset. + - Unified Dataset is `tc.dataset.unified.Dataset` + - Any other Dataset is `tc.dataset.dataset.Dataset` + - Added function to get unified dataset from its project + - Added function to commit unified dataset + - [#367](https://github.com/Datatamer/tamr-client/issues/367) Support for projects: - generic projects via `tc.project` - Mastering projects via `tc.mastering.project` From 94387bd1141aa991836cf7033831ea96084bd5a8 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Mon, 15 Jun 2020 14:20:31 -0400 Subject: [PATCH 419/632] Changed type in record.stream to AnyDataset --- tamr_client/dataset/record.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tamr_client/dataset/record.py b/tamr_client/dataset/record.py index 86d679b3..6309d576 100644 --- a/tamr_client/dataset/record.py +++ b/tamr_client/dataset/record.py @@ -8,7 +8,7 @@ from typing import cast, Dict, IO, Iterable, Iterator, Optional from tamr_client import response -from tamr_client.dataset.dataset import Dataset +from tamr_client.dataset.dataset import AnyDataset, Dataset from tamr_client.session import Session from tamr_client.types import JsonDict @@ -145,7 +145,7 @@ def _delete_command(record: Dict, *, primary_key_name: str) -> Dict: return {"action": "DELETE", "recordId": record[primary_key_name]} -def stream(session: Session, dataset: Dataset) -> Iterator[JsonDict]: +def stream(session: Session, dataset: AnyDataset) -> Iterator[JsonDict]: """Stream the records in this dataset as Python dictionaries. Args: From 534f6cf3214e16dbb8d564331342839cbe0803ea Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Wed, 17 Jun 2020 14:16:42 -0400 Subject: [PATCH 420/632] Changed the name of the unified.Dataset dataclass to unified.UnifiedDataset. Also renamed test_unified_dataset.py to test_unified.py --- docs/beta/dataset/unified.rst | 2 +- tamr_client/dataset/dataset.py | 2 +- tamr_client/dataset/unified.py | 14 ++++++++------ .../{test_unified_dataset.py => test_unified.py} | 0 4 files changed, 10 insertions(+), 8 deletions(-) rename tests/tamr_client/dataset/{test_unified_dataset.py => test_unified.py} (100%) diff --git a/docs/beta/dataset/unified.rst b/docs/beta/dataset/unified.rst index 4e3f62cd..245b1634 100644 --- a/docs/beta/dataset/unified.rst +++ b/docs/beta/dataset/unified.rst @@ -1,7 +1,7 @@ Unified ======= -.. autoclass:: tamr_client.dataset.unified.Dataset +.. autoclass:: tamr_client.dataset.unified.UnifiedDataset .. autofunction:: tamr_client.dataset.unified.from_project .. autofunction:: tamr_client.dataset.unified.commit diff --git a/tamr_client/dataset/dataset.py b/tamr_client/dataset/dataset.py index c30154fb..b223a00b 100644 --- a/tamr_client/dataset/dataset.py +++ b/tamr_client/dataset/dataset.py @@ -6,7 +6,7 @@ from typing import Optional, Tuple, Union from tamr_client import response -from tamr_client.dataset.unified import Dataset as UnifiedDataset +from tamr_client.dataset.unified import UnifiedDataset from tamr_client.instance import Instance from tamr_client.session import Session from tamr_client.types import JsonDict diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index f9c158b8..1c6274b3 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -22,7 +22,7 @@ class NotFound(Exception): @dataclass(frozen=True) -class Dataset: +class UnifiedDataset: """A Tamr unified dataset See https://docs.tamr.com/reference/dataset-models @@ -38,7 +38,9 @@ class Dataset: description: Optional[str] = None -def from_project(session: Session, instance: Instance, project: Project) -> Dataset: +def from_project( + session: Session, instance: Instance, project: Project +) -> UnifiedDataset: """Get unified dataset of a project Fetches the unified dataset of a given project from Tamr server @@ -56,7 +58,7 @@ def from_project(session: Session, instance: Instance, project: Project) -> Data return _from_url(session, url) -def _from_url(session: Session, url: URL) -> Dataset: +def _from_url(session: Session, url: URL) -> UnifiedDataset: """Get dataset by URL Fetches dataset from Tamr server @@ -76,7 +78,7 @@ def _from_url(session: Session, url: URL) -> Dataset: return _from_json(url, data) -def _from_json(url: URL, data: JsonDict) -> Dataset: +def _from_json(url: URL, data: JsonDict) -> UnifiedDataset: """Make unified dataset from JSON data (deserialize) Args: @@ -84,7 +86,7 @@ def _from_json(url: URL, data: JsonDict) -> Dataset: data: Unified Dataset JSON data from Tamr server """ cp = deepcopy(data) - return Dataset( + return UnifiedDataset( url, name=cp["name"], description=cp.get("description"), @@ -92,7 +94,7 @@ def _from_json(url: URL, data: JsonDict) -> Dataset: ) -def commit(session: Session, unified_dataset: Dataset) -> JsonDict: +def commit(session: Session, unified_dataset: UnifiedDataset) -> JsonDict: """Commits the Unified Dataset. Args: diff --git a/tests/tamr_client/dataset/test_unified_dataset.py b/tests/tamr_client/dataset/test_unified.py similarity index 100% rename from tests/tamr_client/dataset/test_unified_dataset.py rename to tests/tamr_client/dataset/test_unified.py From 351944781ed9c78bf025c61d2c5bfa9fe554a284 Mon Sep 17 00:00:00 2001 From: Ian Bakst Date: Wed, 17 Jun 2020 14:26:42 -0400 Subject: [PATCH 421/632] Missed one enified.Dataset to change --- tests/tamr_client/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tamr_client/utils.py b/tests/tamr_client/utils.py index 9154f5a4..95b4af11 100644 --- a/tests/tamr_client/utils.py +++ b/tests/tamr_client/utils.py @@ -30,7 +30,7 @@ def dataset(): def unified_dataset(): url = tc.URL(path="projects/1/unifiedDataset") - unified_dataset = tc.dataset.unified.Dataset( + unified_dataset = tc.dataset.unified.UnifiedDataset( url, name="dataset.csv", key_attribute_names=("primary_key",) ) return unified_dataset From 7b8dce9cfb06206f3807dadbf5c7bde3f5eb9fa4 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 17 Jun 2020 17:18:47 -0400 Subject: [PATCH 422/632] Add operations to TC. --- tamr_client/__init__.py | 4 + tamr_client/operation.py | 168 ++++++++++++++++++ ...{operation.json => operation_pending.json} | 0 3 files changed, 172 insertions(+) create mode 100644 tamr_client/operation.py rename tests/tamr_client/data/{operation.json => operation_pending.json} (100%) diff --git a/tamr_client/__init__.py b/tamr_client/__init__.py index 15f25d2f..43ec7d2b 100644 --- a/tamr_client/__init__.py +++ b/tamr_client/__init__.py @@ -67,3 +67,7 @@ from tamr_client import mastering from tamr_client import project + +# operations +from tamr_client.operation import Operation +from tamr_client import operation diff --git a/tamr_client/operation.py b/tamr_client/operation.py new file mode 100644 index 00000000..a0301ad5 --- /dev/null +++ b/tamr_client/operation.py @@ -0,0 +1,168 @@ +""" +See https://docs.tamr.com/new/reference/the-operation-object +""" +from copy import deepcopy +from dataclasses import dataclass +from time import sleep, time as now +from typing import Dict, Optional + +import requests + +from tamr_client import response +from tamr_client.instance import Instance +from tamr_client.session import Session +from tamr_client.types import JsonDict +from tamr_client.url import URL + + +class OperationNotFound(Exception): + """Raised when referencing an operation that does not exist on the server. + """ + + pass + + +@dataclass(frozen=True) +class Operation: + """A Tamr operation + + See https://docs.tamr.com/new/reference/the-operation-object + + Args: + url + """ + + url: URL + id: str + type: str + status: Optional[Dict[str, str]] = None + description: Optional[str] = None + + +def from_response(instance: Instance, response: requests.Response) -> Operation: + """ + Handle idiosyncrasies in constructing Operations from Tamr responses. + When a Tamr API call would start an operation, but all results that would be + produced by that operation are already up-to-date, Tamr returns `HTTP 204 No Content` + + To make it easy for client code to handle these API responses without checking + the response code, this method will either construct an Operation, or a + dummy `NoOp` operation representing the 204 Success response. + + Args: + response: HTTP Response from the request that started the operation. + """ + if response.status_code == 204: + # Operation was successful, but the response contains no content. + # Create a dummy operation to represent this. + _never = "0000-00-00T00:00:00.000Z" + _description = """Tamr returned HTTP 204 for this operation, indicating that all + results that would be produced by the operation are already up-to-date.""" + resource_json = { + "id": "-1", + "type": "NOOP", + "description": _description, + "status": { + "state": "SUCCEEDED", + "startTime": _never, + "endTime": _never, + "message": "", + }, + "created": {"username": "", "time": _never, "version": "-1"}, + "lastModified": {"username": "", "time": _never, "version": "-1"}, + "relativeId": "operations/-1", + } + _url = URL("noop") + else: + resource_json = response.json() + _id = resource_json["id"] + _url = URL(instance=instance, path=f"operations/{_id}") + return _from_json(_url, resource_json) + + +def _from_url(session: Session, url: URL) -> Operation: + """Get operation by URL + + Fetches operation from Tamr server + + Args: + url: Operation URL + + Raises: + OperationNotFound: If no operation could be found at the specified URL. + Corresponds to a 404 HTTP error. + requests.HTTPError: If any other HTTP error is encountered. + """ + r = session.get(str(url)) + if r.status_code == 404: + raise OperationNotFound(str(url)) + data = response.successful(r).json() + return _from_json(url, data) + + +def _from_json(url: URL, data: JsonDict): + """Make operation from JSON data (deserialize) + + Args: + url: Operation URL + data: Operation JSON data from Tamr server + """ + cp = deepcopy(data) + return Operation( + url, + id=cp["id"], + type=cp["type"], + status=cp.get("status"), + description=cp.get("description"), + ) + + +def poll(session: Session, operation: Operation) -> Operation: + """Poll this operation for server-side updates. + + Does not update the :class:`~tamr_client.operation.Operation` object. + Instead, returns a new :class:`~tamr_client.operation.Operation`. + + Args: + operation: Operation to be polled. + """ + return _from_url(session, operation.url) + + +def wait( + session: Session, + operation: Operation, + *, + poll_interval_seconds: int = 3, + timeout_seconds: Optional[int] = None, +) -> Operation: + """Continuously polls for this operation's server-side state. + + Args: + operation: Operation to be polled. + poll_interval_seconds: Time interval (in seconds) between subsequent polls. + timeout_seconds: Time (in seconds) to wait for operation to resolve. + + Raises: + TimeoutError: If operation takes longer than `timeout_seconds` to resolve. + """ + started = now() + while timeout_seconds is None or now() - started < timeout_seconds: + if operation.status and operation.status["state"] in ["PENDING", "RUNNING"]: + sleep(poll_interval_seconds) + elif not operation.status or operation.status["state"] in [ + "CANCELED", + "SUCCEEDED", + "FAILED", + ]: + return operation + operation = poll(session, operation) + raise TimeoutError( + f"Waiting for operation took longer than {timeout_seconds} seconds." + ) + + +def succeeded(operation: Operation) -> bool: + """Convenience method for checking if operation was successful. + """ + return operation.status is not None and operation.status["state"] == "SUCCEEDED" diff --git a/tests/tamr_client/data/operation.json b/tests/tamr_client/data/operation_pending.json similarity index 100% rename from tests/tamr_client/data/operation.json rename to tests/tamr_client/data/operation_pending.json From 7dd44c74ab5587a9d00c0106a6027f8d77d104f6 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 17 Jun 2020 17:19:14 -0400 Subject: [PATCH 423/632] Add (some) testing of operations. --- .../tamr_client/data/operation_succeeded.json | 22 ++++++ tests/tamr_client/dataset/test_unified.py | 2 +- tests/tamr_client/test_operation.py | 69 +++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 tests/tamr_client/data/operation_succeeded.json create mode 100644 tests/tamr_client/test_operation.py diff --git a/tests/tamr_client/data/operation_succeeded.json b/tests/tamr_client/data/operation_succeeded.json new file mode 100644 index 00000000..010d7ef0 --- /dev/null +++ b/tests/tamr_client/data/operation_succeeded.json @@ -0,0 +1,22 @@ +{ + "id": "1", + "type": "SPARK", + "description": "operation 1 description", + "status": { + "state": "SUCCEEDED", + "startTime": "", + "endTime": "", + "message": "" + }, + "created": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 created version" + }, + "lastModified": { + "username": "admin", + "time": "2020-06-12T18:21:42.288Z", + "version": "operation 1 modified version" + }, + "relativeId": "operations/1" +} \ No newline at end of file diff --git a/tests/tamr_client/dataset/test_unified.py b/tests/tamr_client/dataset/test_unified.py index 3c56ff2a..1ce98592 100644 --- a/tests/tamr_client/dataset/test_unified.py +++ b/tests/tamr_client/dataset/test_unified.py @@ -40,7 +40,7 @@ def test_commit(): instance = utils.instance() project = utils.mastering_project() - operation_json = utils.load_json("operation.json") + operation_json = utils.load_json("operation_pending.json") dataset_json = utils.load_json("dataset.json") prj_url = tc.URL(path="projects/1/unifiedDataset") responses.add(responses.GET, str(prj_url), json=dataset_json) diff --git a/tests/tamr_client/test_operation.py b/tests/tamr_client/test_operation.py new file mode 100644 index 00000000..de4cbe12 --- /dev/null +++ b/tests/tamr_client/test_operation.py @@ -0,0 +1,69 @@ +import responses + +import tamr_client as tc +from tests.tamr_client import utils + + +def test_operation_from_json(): + url = tc.URL(path="operations/1") + operation_json = utils.load_json("operation_succeeded.json") + op = tc.operation._from_json(url, operation_json) + assert op.url == url + assert op.id == operation_json["id"] + assert op.type == operation_json["type"] + assert op.description == operation_json["description"] + assert op.status == operation_json["status"] + assert tc.operation.succeeded(op) + + +@responses.activate +def test_operation_from_url(): + s = utils.session() + url = tc.URL(path="operations/1") + + operation_json = utils.load_json("operation_succeeded.json") + responses.add(responses.GET, str(url), json=operation_json) + + op = tc.operation._from_url(s, url) + assert op.url == url + assert op.id == operation_json["id"] + assert op.type == operation_json["type"] + assert op.description == operation_json["description"] + assert op.status == operation_json["status"] + assert tc.operation.succeeded(op) + + +@responses.activate +def test_operation_from_response(): + s = utils.session() + instance = utils.instance() + url = tc.URL(path="operations/1") + + operation_json = utils.load_json("operation_succeeded.json") + responses.add(responses.GET, str(url), json=operation_json) + + r = s.get(str(url)) + op = tc.operation.from_response(instance, r) + assert op.url == url + assert op.id == operation_json["id"] + assert op.type == operation_json["type"] + assert op.description == operation_json["description"] + assert op.status == operation_json["status"] + assert tc.operation.succeeded(op) + + +@responses.activate +def test_operation_poll(): + s = utils.session() + url = tc.URL(path="operations/1") + + pending_operation_json = utils.load_json("operation_pending.json") + op1 = tc.operation._from_json(url, pending_operation_json) + + succeeded_operation_json = utils.load_json("operation_succeeded.json") + responses.add(responses.GET, str(url), json=succeeded_operation_json) + op2 = tc.operation.poll(s, op1) + + assert op2.id == op1.id + assert not tc.operation.succeeded(op1) + assert tc.operation.succeeded(op2) From aa3143587094294d64a77c41904d946b1e61843e Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 17 Jun 2020 17:49:57 -0400 Subject: [PATCH 424/632] Add apply_options function and more testing. --- tamr_client/operation.py | 37 +++++++++++++++++++++++++---- tests/tamr_client/test_operation.py | 31 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/tamr_client/operation.py b/tamr_client/operation.py index a0301ad5..db4ad393 100644 --- a/tamr_client/operation.py +++ b/tamr_client/operation.py @@ -15,7 +15,7 @@ from tamr_client.url import URL -class OperationNotFound(Exception): +class NotFound(Exception): """Raised when referencing an operation that does not exist on the server. """ @@ -72,14 +72,41 @@ def from_response(instance: Instance, response: requests.Response) -> Operation: "lastModified": {"username": "", "time": _never, "version": "-1"}, "relativeId": "operations/-1", } - _url = URL("noop") else: resource_json = response.json() - _id = resource_json["id"] - _url = URL(instance=instance, path=f"operations/{_id}") + _id = resource_json["id"] + _url = URL(instance=instance, path=f"operations/{_id}") return _from_json(_url, resource_json) +def apply_options( + session: Session, operation: Operation, *, asynchronous: bool = False, **options +) -> Operation: + """Applies operation options to this operation. + + **NOTE**: This function **should not** be called directly. Rather, options should be + passed in through a higher-level function e.g. :func:`~tamr_client.dataset.unified.commit`. + + Synchronous mode: + Automatically waits for operation to resolve before returning the + operation. + + asynchronous mode: + Immediately return the ``'PENDING'`` operation. It is + up to the user to coordinate this operation with their code via + :func:`~tamr_client.operation.wait` and/or + :func:`~tamr_client.operation.poll` . + + Args: + asynchronous: Whether or not to run in asynchronous mode. Default: ``False``. + ``**options``: When running in synchronous mode, these options are + passed to the underlying :func:`~tamr_client.operation.wait` call. + """ + if asynchronous: + return operation + return wait(session, operation, **options) + + def _from_url(session: Session, url: URL) -> Operation: """Get operation by URL @@ -95,7 +122,7 @@ def _from_url(session: Session, url: URL) -> Operation: """ r = session.get(str(url)) if r.status_code == 404: - raise OperationNotFound(str(url)) + raise NotFound(str(url)) data = response.successful(r).json() return _from_json(url, data) diff --git a/tests/tamr_client/test_operation.py b/tests/tamr_client/test_operation.py index de4cbe12..8fd71099 100644 --- a/tests/tamr_client/test_operation.py +++ b/tests/tamr_client/test_operation.py @@ -1,3 +1,4 @@ +import pytest import responses import tamr_client as tc @@ -52,6 +53,36 @@ def test_operation_from_response(): assert tc.operation.succeeded(op) +@responses.activate +def test_operation_from_response_noop(): + s = utils.session() + instance = utils.instance() + url = tc.URL(path="operations/2") + responses.add(responses.GET, str(url), status=204) + + url_dummy = tc.URL(path="operations/-1") + responses.add(responses.GET, str(url_dummy), status=404) + + r = s.get(str(url)) + op2 = tc.operation.from_response(instance, r) + + assert op2.id is not None + assert op2.type == "NOOP" + assert op2.description is not None + assert op2.status is not None + assert op2.status["state"] == "SUCCEEDED" + assert tc.operation.succeeded(op2) + + op2a = tc.operation.apply_options(s, op2, asynchronous=True) + assert tc.operation.succeeded(op2a) + + op2w = tc.operation.wait(s, op2a) + assert tc.operation.succeeded(op2w) + + with pytest.raises(tc.operation.NotFound): + tc.operation.poll(s, op2w) + + @responses.activate def test_operation_poll(): s = utils.session() From 2d526e92916d7df5fb0effc60b9c51237b187ecf Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 18 Jun 2020 10:26:45 -0400 Subject: [PATCH 425/632] Prefix internal functions with underscore. --- tamr_client/operation.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tamr_client/operation.py b/tamr_client/operation.py index db4ad393..1e484c0e 100644 --- a/tamr_client/operation.py +++ b/tamr_client/operation.py @@ -39,7 +39,7 @@ class Operation: description: Optional[str] = None -def from_response(instance: Instance, response: requests.Response) -> Operation: +def _from_response(instance: Instance, response: requests.Response) -> Operation: """ Handle idiosyncrasies in constructing Operations from Tamr responses. When a Tamr API call would start an operation, but all results that would be @@ -79,15 +79,12 @@ def from_response(instance: Instance, response: requests.Response) -> Operation: return _from_json(_url, resource_json) -def apply_options( +def _apply_options( session: Session, operation: Operation, *, asynchronous: bool = False, **options ) -> Operation: """Applies operation options to this operation. - **NOTE**: This function **should not** be called directly. Rather, options should be - passed in through a higher-level function e.g. :func:`~tamr_client.dataset.unified.commit`. - - Synchronous mode: + synchronous mode: Automatically waits for operation to resolve before returning the operation. From e0b9b01132fc962bfc4194080c70b1ffda92f9b7 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 18 Jun 2020 10:28:44 -0400 Subject: [PATCH 426/632] Move intended interface functions to beginning of file. --- tamr_client/operation.py | 138 +++++++++++++++++++-------------------- 1 file changed, 69 insertions(+), 69 deletions(-) diff --git a/tamr_client/operation.py b/tamr_client/operation.py index 1e484c0e..3aa202eb 100644 --- a/tamr_client/operation.py +++ b/tamr_client/operation.py @@ -39,6 +39,57 @@ class Operation: description: Optional[str] = None +def poll(session: Session, operation: Operation) -> Operation: + """Poll this operation for server-side updates. + + Does not update the :class:`~tamr_client.operation.Operation` object. + Instead, returns a new :class:`~tamr_client.operation.Operation`. + + Args: + operation: Operation to be polled. + """ + return _from_url(session, operation.url) + + +def wait( + session: Session, + operation: Operation, + *, + poll_interval_seconds: int = 3, + timeout_seconds: Optional[int] = None, +) -> Operation: + """Continuously polls for this operation's server-side state. + + Args: + operation: Operation to be polled. + poll_interval_seconds: Time interval (in seconds) between subsequent polls. + timeout_seconds: Time (in seconds) to wait for operation to resolve. + + Raises: + TimeoutError: If operation takes longer than `timeout_seconds` to resolve. + """ + started = now() + while timeout_seconds is None or now() - started < timeout_seconds: + if operation.status and operation.status["state"] in ["PENDING", "RUNNING"]: + sleep(poll_interval_seconds) + elif not operation.status or operation.status["state"] in [ + "CANCELED", + "SUCCEEDED", + "FAILED", + ]: + return operation + operation = poll(session, operation) + raise TimeoutError( + f"Waiting for operation took longer than {timeout_seconds} seconds." + ) + + +def succeeded(operation: Operation) -> bool: + """Convenience method for checking if operation was successful. + """ + return operation.status is not None and operation.status["state"] == "SUCCEEDED" + + def _from_response(instance: Instance, response: requests.Response) -> Operation: """ Handle idiosyncrasies in constructing Operations from Tamr responses. @@ -79,31 +130,6 @@ def _from_response(instance: Instance, response: requests.Response) -> Operation return _from_json(_url, resource_json) -def _apply_options( - session: Session, operation: Operation, *, asynchronous: bool = False, **options -) -> Operation: - """Applies operation options to this operation. - - synchronous mode: - Automatically waits for operation to resolve before returning the - operation. - - asynchronous mode: - Immediately return the ``'PENDING'`` operation. It is - up to the user to coordinate this operation with their code via - :func:`~tamr_client.operation.wait` and/or - :func:`~tamr_client.operation.poll` . - - Args: - asynchronous: Whether or not to run in asynchronous mode. Default: ``False``. - ``**options``: When running in synchronous mode, these options are - passed to the underlying :func:`~tamr_client.operation.wait` call. - """ - if asynchronous: - return operation - return wait(session, operation, **options) - - def _from_url(session: Session, url: URL) -> Operation: """Get operation by URL @@ -141,52 +167,26 @@ def _from_json(url: URL, data: JsonDict): ) -def poll(session: Session, operation: Operation) -> Operation: - """Poll this operation for server-side updates. - - Does not update the :class:`~tamr_client.operation.Operation` object. - Instead, returns a new :class:`~tamr_client.operation.Operation`. - - Args: - operation: Operation to be polled. - """ - return _from_url(session, operation.url) - - -def wait( - session: Session, - operation: Operation, - *, - poll_interval_seconds: int = 3, - timeout_seconds: Optional[int] = None, +def _apply_options( + session: Session, operation: Operation, *, asynchronous: bool = False, **options ) -> Operation: - """Continuously polls for this operation's server-side state. - - Args: - operation: Operation to be polled. - poll_interval_seconds: Time interval (in seconds) between subsequent polls. - timeout_seconds: Time (in seconds) to wait for operation to resolve. + """Applies operation options to this operation. - Raises: - TimeoutError: If operation takes longer than `timeout_seconds` to resolve. - """ - started = now() - while timeout_seconds is None or now() - started < timeout_seconds: - if operation.status and operation.status["state"] in ["PENDING", "RUNNING"]: - sleep(poll_interval_seconds) - elif not operation.status or operation.status["state"] in [ - "CANCELED", - "SUCCEEDED", - "FAILED", - ]: - return operation - operation = poll(session, operation) - raise TimeoutError( - f"Waiting for operation took longer than {timeout_seconds} seconds." - ) + synchronous mode: + Automatically waits for operation to resolve before returning the + operation. + asynchronous mode: + Immediately return the ``'PENDING'`` operation. It is + up to the user to coordinate this operation with their code via + :func:`~tamr_client.operation.wait` and/or + :func:`~tamr_client.operation.poll` . -def succeeded(operation: Operation) -> bool: - """Convenience method for checking if operation was successful. + Args: + asynchronous: Whether or not to run in asynchronous mode. Default: ``False``. + ``**options``: When running in synchronous mode, these options are + passed to the underlying :func:`~tamr_client.operation.wait` call. """ - return operation.status is not None and operation.status["state"] == "SUCCEEDED" + if asynchronous: + return operation + return wait(session, operation, **options) From c7511e32d28437c2324a3fb44e97055dad738d6c Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 18 Jun 2020 10:33:20 -0400 Subject: [PATCH 427/632] Simplify logic in wait function. --- tamr_client/operation.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tamr_client/operation.py b/tamr_client/operation.py index 3aa202eb..69ea126c 100644 --- a/tamr_client/operation.py +++ b/tamr_client/operation.py @@ -70,13 +70,11 @@ def wait( """ started = now() while timeout_seconds is None or now() - started < timeout_seconds: - if operation.status and operation.status["state"] in ["PENDING", "RUNNING"]: + if operation.status is None: + return operation + elif operation.status["state"] in ["PENDING", "RUNNING"]: sleep(poll_interval_seconds) - elif not operation.status or operation.status["state"] in [ - "CANCELED", - "SUCCEEDED", - "FAILED", - ]: + elif operation.status["state"] in ["CANCELED", "SUCCEEDED", "FAILED"]: return operation operation = poll(session, operation) raise TimeoutError( From caa21baf7f39134e9ec70ef8ba2b9fcdcc04de35 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 18 Jun 2020 10:47:13 -0400 Subject: [PATCH 428/632] Remove attribute id from Operation, redundant with url. Update tests to match changes. --- tamr_client/operation.py | 7 +------ tests/tamr_client/test_operation.py | 13 +++++-------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/tamr_client/operation.py b/tamr_client/operation.py index 69ea126c..565c3b3a 100644 --- a/tamr_client/operation.py +++ b/tamr_client/operation.py @@ -33,7 +33,6 @@ class Operation: """ url: URL - id: str type: str status: Optional[Dict[str, str]] = None description: Optional[str] = None @@ -157,11 +156,7 @@ def _from_json(url: URL, data: JsonDict): """ cp = deepcopy(data) return Operation( - url, - id=cp["id"], - type=cp["type"], - status=cp.get("status"), - description=cp.get("description"), + url, type=cp["type"], status=cp.get("status"), description=cp.get("description") ) diff --git a/tests/tamr_client/test_operation.py b/tests/tamr_client/test_operation.py index 8fd71099..b62cc559 100644 --- a/tests/tamr_client/test_operation.py +++ b/tests/tamr_client/test_operation.py @@ -10,7 +10,6 @@ def test_operation_from_json(): operation_json = utils.load_json("operation_succeeded.json") op = tc.operation._from_json(url, operation_json) assert op.url == url - assert op.id == operation_json["id"] assert op.type == operation_json["type"] assert op.description == operation_json["description"] assert op.status == operation_json["status"] @@ -27,7 +26,6 @@ def test_operation_from_url(): op = tc.operation._from_url(s, url) assert op.url == url - assert op.id == operation_json["id"] assert op.type == operation_json["type"] assert op.description == operation_json["description"] assert op.status == operation_json["status"] @@ -44,9 +42,8 @@ def test_operation_from_response(): responses.add(responses.GET, str(url), json=operation_json) r = s.get(str(url)) - op = tc.operation.from_response(instance, r) + op = tc.operation._from_response(instance, r) assert op.url == url - assert op.id == operation_json["id"] assert op.type == operation_json["type"] assert op.description == operation_json["description"] assert op.status == operation_json["status"] @@ -64,16 +61,16 @@ def test_operation_from_response_noop(): responses.add(responses.GET, str(url_dummy), status=404) r = s.get(str(url)) - op2 = tc.operation.from_response(instance, r) + op2 = tc.operation._from_response(instance, r) - assert op2.id is not None + assert op2.url is not None assert op2.type == "NOOP" assert op2.description is not None assert op2.status is not None assert op2.status["state"] == "SUCCEEDED" assert tc.operation.succeeded(op2) - op2a = tc.operation.apply_options(s, op2, asynchronous=True) + op2a = tc.operation._apply_options(s, op2, asynchronous=True) assert tc.operation.succeeded(op2a) op2w = tc.operation.wait(s, op2a) @@ -95,6 +92,6 @@ def test_operation_poll(): responses.add(responses.GET, str(url), json=succeeded_operation_json) op2 = tc.operation.poll(s, op1) - assert op2.id == op1.id + assert op2.url == op1.url assert not tc.operation.succeeded(op1) assert tc.operation.succeeded(op2) From a8e8357d0f80ba7ee39faab5a1e3f22f9e46e2d9 Mon Sep 17 00:00:00 2001 From: skalish Date: Thu, 18 Jun 2020 11:26:56 -0400 Subject: [PATCH 429/632] Include all user-facing attributes in docstring. --- tamr_client/operation.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tamr_client/operation.py b/tamr_client/operation.py index 565c3b3a..629c1d69 100644 --- a/tamr_client/operation.py +++ b/tamr_client/operation.py @@ -30,6 +30,9 @@ class Operation: Args: url + type + status + description """ url: URL From dfcfa944fb1e2453132fdd32f523bd98478be513 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 24 Jun 2020 14:47:38 -0400 Subject: [PATCH 430/632] Remove _apply_options and change unified dataset function to return an operation. --- docs/beta/dataset/unified.rst | 2 +- tamr_client/dataset/unified.py | 24 ++++++++++++++++------ tamr_client/operation.py | 25 ----------------------- tests/tamr_client/dataset/test_unified.py | 17 +++++++-------- tests/tamr_client/test_operation.py | 5 +---- 5 files changed, 27 insertions(+), 46 deletions(-) diff --git a/docs/beta/dataset/unified.rst b/docs/beta/dataset/unified.rst index 245b1634..78054d33 100644 --- a/docs/beta/dataset/unified.rst +++ b/docs/beta/dataset/unified.rst @@ -4,7 +4,7 @@ Unified .. autoclass:: tamr_client.dataset.unified.UnifiedDataset .. autofunction:: tamr_client.dataset.unified.from_project -.. autofunction:: tamr_client.dataset.unified.commit +.. autofunction:: tamr_client.dataset.unified.apply_changes Exceptions ---------- diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index 1c6274b3..c3e35572 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -5,8 +5,9 @@ from dataclasses import dataclass from typing import Optional, Tuple -from tamr_client import response +from tamr_client import operation, response from tamr_client.instance import Instance +from tamr_client.operation import Operation from tamr_client.project import Project from tamr_client.session import Session from tamr_client.types import JsonDict @@ -94,15 +95,26 @@ def _from_json(url: URL, data: JsonDict) -> UnifiedDataset: ) -def commit(session: Session, unified_dataset: UnifiedDataset) -> JsonDict: - """Commits the Unified Dataset. +def apply_changes(session: Session, unified_dataset: UnifiedDataset) -> Operation: + """Applies changes to the unified dataset and waits for the operation to resolve Args: - unified_dataset: The UnifiedDataset which will be committed - session: The Tamr Session + unified_dataset: The Unified Dataset which will be committed + """ + op = _apply_changes_async(session, unified_dataset) + return operation.wait(session, op) + + +def _apply_changes_async( + session: Session, unified_dataset: UnifiedDataset +) -> Operation: + """Applies changes to the unified dataset + + Args: + unified_dataset: The Unified Dataset which will be committed """ r = session.post( str(unified_dataset.url) + ":refresh", headers={"Content-Type": "application/json", "Accept": "application/json"}, ) - return response.successful(r).json() + return operation._from_response(unified_dataset.url.instance, r) diff --git a/tamr_client/operation.py b/tamr_client/operation.py index 629c1d69..22c0bdc5 100644 --- a/tamr_client/operation.py +++ b/tamr_client/operation.py @@ -161,28 +161,3 @@ def _from_json(url: URL, data: JsonDict): return Operation( url, type=cp["type"], status=cp.get("status"), description=cp.get("description") ) - - -def _apply_options( - session: Session, operation: Operation, *, asynchronous: bool = False, **options -) -> Operation: - """Applies operation options to this operation. - - synchronous mode: - Automatically waits for operation to resolve before returning the - operation. - - asynchronous mode: - Immediately return the ``'PENDING'`` operation. It is - up to the user to coordinate this operation with their code via - :func:`~tamr_client.operation.wait` and/or - :func:`~tamr_client.operation.poll` . - - Args: - asynchronous: Whether or not to run in asynchronous mode. Default: ``False``. - ``**options``: When running in synchronous mode, these options are - passed to the underlying :func:`~tamr_client.operation.wait` call. - """ - if asynchronous: - return operation - return wait(session, operation, **options) diff --git a/tests/tamr_client/dataset/test_unified.py b/tests/tamr_client/dataset/test_unified.py index 1ce98592..38089aaa 100644 --- a/tests/tamr_client/dataset/test_unified.py +++ b/tests/tamr_client/dataset/test_unified.py @@ -35,19 +35,16 @@ def test_from_project_dataset_not_found(): @responses.activate -def test_commit(): +def test_apply_changes(): s = utils.session() - instance = utils.instance() - project = utils.mastering_project() - - operation_json = utils.load_json("operation_pending.json") dataset_json = utils.load_json("dataset.json") - prj_url = tc.URL(path="projects/1/unifiedDataset") - responses.add(responses.GET, str(prj_url), json=dataset_json) - unified_dataset = tc.dataset.unified.from_project(s, instance, project) + dataset_url = tc.URL(path="projects/1/unifiedDataset") + unified_dataset = tc.dataset.unified._from_json(dataset_url, dataset_json) + operation_json = utils.load_json("operation_pending.json") + operation_url = tc.URL(path="operations/1") url = tc.URL(path="projects/1/unifiedDataset:refresh") responses.add(responses.POST, str(url), json=operation_json) - response = tc.dataset.unified.commit(s, unified_dataset) - assert response == operation_json + response = tc.dataset.unified._apply_changes_async(s, unified_dataset) + assert response == tc.operation._from_json(operation_url, operation_json) diff --git a/tests/tamr_client/test_operation.py b/tests/tamr_client/test_operation.py index b62cc559..35d1ad32 100644 --- a/tests/tamr_client/test_operation.py +++ b/tests/tamr_client/test_operation.py @@ -70,10 +70,7 @@ def test_operation_from_response_noop(): assert op2.status["state"] == "SUCCEEDED" assert tc.operation.succeeded(op2) - op2a = tc.operation._apply_options(s, op2, asynchronous=True) - assert tc.operation.succeeded(op2a) - - op2w = tc.operation.wait(s, op2a) + op2w = tc.operation.wait(s, op2) assert tc.operation.succeeded(op2w) with pytest.raises(tc.operation.NotFound): From a5dbe76cbe1429d3a65f0fd89fe6d1a5879f2c5f Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 24 Jun 2020 14:51:07 -0400 Subject: [PATCH 431/632] Include missing arguments in Dataset and UnifiedDataset docstrings. --- tamr_client/dataset/dataset.py | 2 ++ tamr_client/dataset/unified.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/tamr_client/dataset/dataset.py b/tamr_client/dataset/dataset.py index b223a00b..87bde613 100644 --- a/tamr_client/dataset/dataset.py +++ b/tamr_client/dataset/dataset.py @@ -29,7 +29,9 @@ class Dataset: Args: url + name key_attribute_names + description """ url: URL diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index c3e35572..1083350c 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -30,7 +30,9 @@ class UnifiedDataset: Args: url + name key_attribute_names + description """ url: URL From eab3fe9bbf069cdbdead34588c76c156dbb97d43 Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 24 Jun 2020 15:04:19 -0400 Subject: [PATCH 432/632] Add operation docs. --- docs/beta.md | 1 + docs/beta/operation.rst | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 docs/beta/operation.rst diff --git a/docs/beta.md b/docs/beta.md index ecec02bb..2ebf658f 100644 --- a/docs/beta.md +++ b/docs/beta.md @@ -10,6 +10,7 @@ * [Dataset](beta/dataset) * [Instance](beta/instance) * [Mastering](beta/mastering) + * [Operation](beta/operation) * [Project](beta/project) * [Response](beta/response) * [Session](beta/session) diff --git a/docs/beta/operation.rst b/docs/beta/operation.rst new file mode 100644 index 00000000..54484a9d --- /dev/null +++ b/docs/beta/operation.rst @@ -0,0 +1,8 @@ +Operation +========= + +.. autoclass:: tamr_client.Operation + +.. autofunction:: tamr_client.operation.poll +.. autofunction:: tamr_client.operation.wait +.. autofunction:: tamr_client.operation.succeeded \ No newline at end of file From f9b897a3187e26d3d7f41961b0dd824e5acb722f Mon Sep 17 00:00:00 2001 From: skalish Date: Wed, 24 Jun 2020 15:08:03 -0400 Subject: [PATCH 433/632] Update CHANGELOG. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dde1263f..d3e00aa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - generic projects via `tc.project` - Mastering projects via `tc.mastering.project` - Support for streaming records from a dataset via `tc.record.stream` + - Support for operations via `tc.operations` **BUG FIXES** - `from_geo_features` now returns information on the operation. From ef8b0e4a8ace6f33fc8a7a9abbccf2017bb37f31 Mon Sep 17 00:00:00 2001 From: skalish <39866163+skalish@users.noreply.github.com> Date: Wed, 24 Jun 2020 15:32:54 -0400 Subject: [PATCH 434/632] Make minor wording change Co-authored-by: Pedro Cattori --- tamr_client/dataset/unified.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tamr_client/dataset/unified.py b/tamr_client/dataset/unified.py index 1083350c..a9bfa9cb 100644 --- a/tamr_client/dataset/unified.py +++ b/tamr_client/dataset/unified.py @@ -98,7 +98,7 @@ def _from_json(url: URL, data: JsonDict) -> UnifiedDataset: def apply_changes(session: Session, unified_dataset: UnifiedDataset) -> Operation: - """Applies changes to the unified dataset and waits for the operation to resolve + """Applies changes to the unified dataset and waits for the operation to complete Args: unified_dataset: The Unified Dataset which will be committed From c714caee0cc7b2223135c41fce7bdf3c6b5626ad Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Wed, 24 Jun 2020 15:15:47 -0400 Subject: [PATCH 435/632] Simplify contributor docs Combine install + toolchain docs into new install doc. Add 'commits' section to pull request guide Add VS Code to text editor guide --- docs/contributor-guide.md | 28 ++++++++++++------ docs/contributor-guide/bugs-and-features.md | 9 ------ docs/contributor-guide/config-text-editor.md | 5 ---- .../{run-and-build.md => dev-tasks.md} | 6 ++-- docs/contributor-guide/install.md | 25 ++++++++-------- docs/contributor-guide/migration.md | 5 ---- .../navigating-inheritance.md | 25 ---------------- docs/contributor-guide/pull-request.md | 24 +++++++-------- .../resource:collectionRequest.png | Bin 98891 -> 0 bytes .../resource:collectionRoute.png | Bin 158090 -> 0 bytes docs/contributor-guide/style-guide.md | 2 +- docs/contributor-guide/text-editor.md | 8 +++++ docs/contributor-guide/toolchain.md | 28 ------------------ 13 files changed, 54 insertions(+), 111 deletions(-) delete mode 100644 docs/contributor-guide/bugs-and-features.md delete mode 100644 docs/contributor-guide/config-text-editor.md rename docs/contributor-guide/{run-and-build.md => dev-tasks.md} (97%) delete mode 100644 docs/contributor-guide/migration.md delete mode 100644 docs/contributor-guide/navigating-inheritance.md delete mode 100644 docs/contributor-guide/resource:collectionRequest.png delete mode 100644 docs/contributor-guide/resource:collectionRoute.png create mode 100644 docs/contributor-guide/text-editor.md delete mode 100644 docs/contributor-guide/toolchain.md diff --git a/docs/contributor-guide.md b/docs/contributor-guide.md index 91de4537..99ad370a 100644 --- a/docs/contributor-guide.md +++ b/docs/contributor-guide.md @@ -1,11 +1,21 @@ # Contributor guide - * [Bug Reports and Feature Requests](contributor-guide/bugs-and-features) - * [Code Migrations](contributor-guide/migration) - * [Configure your Text Editor](contributor-guide/config-text-editor) - * [Install](contributor-guide/install) - * [Navigating Inheritance](contributor-guide/navigating-inheritance) - * [Pull Requests](contributor-guide/pull-request) - * [Run and Build](contributor-guide/run-and-build) - * [Style Guide](contributor-guide/style-guide) - * [Toolchain](contributor-guide/toolchain) +## Contributing feedback + +Check through existing issues (open and closed) to confirm that the bug or feature hasn’t been reported before. + +If the bug/feature has been submitted already, leave a like 👍 on the description of the Github Issue. + +### Bug reports +Submit bug reports as [Github issues](https://github.com/Datatamer/tamr-client/issues/new/choose). + +### Feature requests +Submit feature requests as [Github issues](https://github.com/Datatamer/tamr-client/issues/new/choose) + + +## Contributing code +* [Install the codebase](contributor-guide/install) +* [Run dev tasks](contributor-guide/dev-tasks) +* [Configure your text editor](contributor-guide/text-editor) +* [Read the style guide](contributor-guide/style-guide) +* [Submit a pull request](contributor-guide/pull-request) diff --git a/docs/contributor-guide/bugs-and-features.md b/docs/contributor-guide/bugs-and-features.md deleted file mode 100644 index e011b47e..00000000 --- a/docs/contributor-guide/bugs-and-features.md +++ /dev/null @@ -1,9 +0,0 @@ -# Submitting Bug Reports and Feature Requests - -Submit bug reports and feature requests as [Github issues](https://github.com/Datatamer/tamr-client/issues/new/choose) . - ---- - -Check through existing issues (open and closed) to confirm that the bug hasn’t been reported before. - -If the bug/feature has been submitted already, leave a like 👍 on the Github Issue. diff --git a/docs/contributor-guide/config-text-editor.md b/docs/contributor-guide/config-text-editor.md deleted file mode 100644 index 29ee4a4a..00000000 --- a/docs/contributor-guide/config-text-editor.md +++ /dev/null @@ -1,5 +0,0 @@ -# Configure your Text Editor - -- [Atom](https://atom.io/) - - [python-black](https://atom.io/packages/python-black) - - [linter-flake8](https://atom.io/packages/linter-flake8) diff --git a/docs/contributor-guide/run-and-build.md b/docs/contributor-guide/dev-tasks.md similarity index 97% rename from docs/contributor-guide/run-and-build.md rename to docs/contributor-guide/dev-tasks.md index 6486e9c9..f422b831 100644 --- a/docs/contributor-guide/run-and-build.md +++ b/docs/contributor-guide/dev-tasks.md @@ -1,4 +1,4 @@ -# Run and Build +# Run dev tasks This project uses [nox](https://nox.thea.codes/en/stable/). @@ -15,7 +15,7 @@ prn # with alias poetry run nox # without alias ``` -## Linting & Formatting +## Linting To run linter: @@ -24,6 +24,8 @@ prn -s lint # with alias poetry run nox -s lint # without alias ``` +## Formatting + To run formatter: ```sh diff --git a/docs/contributor-guide/install.md b/docs/contributor-guide/install.md index 8107c9ed..89b0d67d 100644 --- a/docs/contributor-guide/install.md +++ b/docs/contributor-guide/install.md @@ -1,9 +1,12 @@ -# Install +# Installation -This project uses `pyenv` and `poetry`. -If you do not have these installed, checkout the [toolchain guide](toolchain). +### Pre-requisites ---- +1. Install [build dependencies for pyenv](https://github.com/pyenv/pyenv/wiki#suggested-build-environment) +2. Install [pyenv](https://github.com/pyenv/pyenv#installation) +3. Install [poetry](https://python-poetry.org/docs/#installation) + +### Clone + install 1. Clone your fork and `cd` into the project: @@ -12,20 +15,16 @@ If you do not have these installed, checkout the [toolchain guide](toolchain). cd tamr-client ``` -2. Set a Python version for this project. Must be Python 3.6+ (e.g. `3.7.3`): - - ```sh - pyenv local 3.7.3 - ``` +2. Install all Python versions in [.python-version](https://github.com/Datatamer/tamr-client/blob/master/.python-version): -3. Check that your Python version matches the version specified in `.python-version`: + [Dev tasks](dev-tasks) will use these Python versions. ```sh - cat .python-version - python --version + # run `pyenv install` for each line in `.python-version` + cat .python-version | xargs -L 1 pyenv install ``` -4. Install dependencies via `poetry`: +3. Install project dependencies via `poetry`: ```sh poetry install diff --git a/docs/contributor-guide/migration.md b/docs/contributor-guide/migration.md deleted file mode 100644 index 62ef1546..00000000 --- a/docs/contributor-guide/migration.md +++ /dev/null @@ -1,5 +0,0 @@ -# Code Migrations - -Some of the codebase is old and outdated. - -To know which patterns to follow and which to avoid, you can check out [ongoing code migrations](https://github.com/Datatamer/tamr-client/labels/%F0%9F%93%88%20Ongoing%20Migration) diff --git a/docs/contributor-guide/navigating-inheritance.md b/docs/contributor-guide/navigating-inheritance.md deleted file mode 100644 index da05b0fd..00000000 --- a/docs/contributor-guide/navigating-inheritance.md +++ /dev/null @@ -1,25 +0,0 @@ -# Navigating Inheritance - -Older parts of the codebase heavily use inheritance. -We are in the process of [migrating to `dataclasses`](https://github.com/Datatamer/tamr-client/issues/309) to simplify the codebase, but in the meantime you might want to know how the inheritance machinery we have works. - ---- - -`yourResource` and `yourCollection` are files that inherit from `baseResource` and `baseCollection`. Examples of such files would be `resource.py` and `collection.py` in the `attribute_configuration` folder under `project`. - -![collection route](resource:collectionRoute.png) -![collection request](resource:collectionRequest.png) - -**Step 1 (red)**: `yourCollection`’s `by_relative_id` returns `super.by_relative_id`, which comes from `baseCollection` - -**Step 1a (black)**: within `by_relative_id`, variable `resource_json` is defined as `self.client.get.[etc]`. `Client`’s `.get` returns `self.request` - -**Step 1b (black)**: `client`’s `.request` makes a request to the provided URL (this is the method actually fetching the data) - -**Step 2 (orange)**: `baseCollection`’s `by_relative_id` returns `resource_class.from_json`, which is the `from_json` defined in `yourResource` - -**Step 3 (yellow)**: `yourResource`’s `from_json` returns `super.from_data`, which comes from `baseResource` - -**Step 4 (green)**: `baseResource`’s `from_data` returns `cls` , one of the parameters entered for `from_data`. -`cls` is a `yourResource`, because in `from_json` the return type is specified to be a `yourResource`. -When `cls` is returned, a `yourResource` that has been filled with the data retrieved in `client`’s `.request` is what comes back. diff --git a/docs/contributor-guide/pull-request.md b/docs/contributor-guide/pull-request.md index 84b8a8ae..3cc5cd89 100644 --- a/docs/contributor-guide/pull-request.md +++ b/docs/contributor-guide/pull-request.md @@ -1,16 +1,16 @@ -# ↪️ Pull Requests +# Contributing pull requests -For larger, new features: +### ️RFCs +If the proposed changes require design input, [open a Request For Comment issue](https://github.com/Datatamer/tamr-client/issues/new/choose). - [Open an RFC issue](https://github.com/Datatamer/tamr-client/issues/new/choose). - Discuss the feature with project maintainers to be sure that your change fits with the project vision and that you won't be wasting effort going in the wrong direction. +Discuss the feature with project maintainers to be sure that your change fits with the project vision and that you won't be wasting effort going in the wrong direction. - Once you get the green light 🚦 from maintainers, you can proceed with the PR. +Once you get the green light 🟢 from maintainers, you can proceed with the PR. ---- +### Pull requests Contributions / PRs should follow the -[Forking Workflow](https://www.atlassian.com/git/tutorials/comparing-workflows/forking-workflow): +[Forking Workflow](https://www.atlassian.com/git/tutorials/comparing-workflows/forking-workflow). In short: 1. Fork it: `https://github.com/[your-github-username]/tamr-client/fork` 2. Create your feature branch: @@ -33,12 +33,8 @@ Contributions / PRs should follow the 5. Create a new Pull Request ----- +### Commits -We optimize for PR readability, so please squash commits before and during the PR review process if you think it will help reviewers and onlookers navigate your changes. +Split and squash commits as necessary to create a clean `git` history. Once you ask for review, only add new commits (do not change existing commits) for reviewer convenience. You may change commits in your PR only if reviewers are ok with it. -Don't be afraid to `push -f` on your PRs when it helps our eyes read your code. - ---- - -Remember to check for any [ongoing code migrations](migration) that may be relevant to your PR. +Also, write [good commit messages](https://chris.beams.io/posts/git-commit/)! \ No newline at end of file diff --git a/docs/contributor-guide/resource:collectionRequest.png b/docs/contributor-guide/resource:collectionRequest.png deleted file mode 100644 index b153519cb9958f90b43f1f9c8e29ab9f0d77bb7a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 98891 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