├── tests ├── apis │ ├── __init__.py │ ├── conftest.py │ ├── cassettes │ │ ├── test_permissions │ │ │ ├── TestPermissions.test_get_user_permissions.yaml │ │ │ ├── TestPermissions.test_list_all_permissions_with_paging.yaml │ │ │ ├── TestPermissions.test_list_all_permissions.yaml │ │ │ ├── TestPermissions.test_add_user_permissions.yaml │ │ │ ├── TestPermissions.test_remove_user_permissions.yaml │ │ │ └── TestPermissions.test_update_user_permissions.yaml │ │ └── test_documents │ │ │ ├── TestDocuments.test_delete_documents.yaml │ │ │ ├── TestDocuments.test_index_documents_source_not_found.yaml │ │ │ ├── TestDocuments.test_delete_documents_source_not_found.yaml │ │ │ └── TestDocuments.test_index_documents.yaml │ ├── test_documents.py │ └── test_permissions.py ├── __init__.py ├── test_client.py └── test_request_session.py ├── MANIFEST.in ├── elastic_workplace_search ├── apis │ ├── __init__.py │ ├── permissions.py │ └── documents.py ├── __init__.py ├── __version__.py ├── client.py ├── exceptions.py ├── utils.py └── request_session.py ├── dev-requirements.txt ├── .ci ├── stop-stack.sh ├── start-stack.sh ├── README.md └── docker-compose.yml ├── setup.cfg ├── noxfile.py ├── CHANGELOG.md ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── setup.py ├── README.md └── LICENSE.txt /tests/apis/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | 3 | -------------------------------------------------------------------------------- /elastic_workplace_search/apis/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | mock 2 | pytest 3 | pytest-recording 4 | -------------------------------------------------------------------------------- /.ci/stop-stack.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -x 4 | 5 | # Stop all stack components 6 | docker-compose -f ./.ci/docker-compose.yml down --timeout 10 7 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # the inclusion of the tests module is not meant to offer best practices for 2 | # testing in general, but rather to support the `find_packages` example in 3 | # setup.py that excludes installing the "tests" package 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | # This flag says that the code is written to work on both Python 2 and Python 3 | # 3. If at all possible, it is good practice to do this. If you cannot, you 4 | # will need to generate wheels for each Python version that you support. 5 | universal=1 6 | -------------------------------------------------------------------------------- /elastic_workplace_search/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import Client, Documents, Permissions, RequestSession 2 | from .__version__ import __version__ # noqa: F401 3 | 4 | __all__ = [ 5 | "Client", 6 | "Documents", 7 | "Permissions", 8 | "RequestSession", 9 | ] 10 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | from elastic_workplace_search.client import Client 2 | 3 | 4 | class TestClient: 5 | dummy_authorization_token = "authorization_token" 6 | 7 | def test_constructor(self): 8 | client = Client(self.dummy_authorization_token) 9 | assert isinstance(client, Client) 10 | -------------------------------------------------------------------------------- /elastic_workplace_search/__version__.py: -------------------------------------------------------------------------------- 1 | __title__ = "elastic_workplace_search" 2 | __description__ = "An API client for Elastic Workplace Search" 3 | __url__ = "https://github.com/elastic/workplace-search-python" 4 | __version__ = "0.3.0" 5 | __author__ = "Elastic" 6 | __author_email__ = "support@elastic.co" 7 | __maintainer__ = "Seth Michael Larson" 8 | __maintainer_email__ = "seth.larson@elastic.co" 9 | -------------------------------------------------------------------------------- /.ci/start-stack.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -x 4 | 5 | # Start stack components 6 | docker-compose -f ./.ci/docker-compose.yml up --detach elasticsearch enterprise-search 7 | 8 | # Wait until the product is up and running 9 | set +x 10 | echo -n 'Waiting for the stack to start (may take a while) .' 11 | until curl --silent --output /dev/null --max-time 1 http://localhost:8080/swiftype-app-version; do 12 | sleep 3; 13 | echo -n '.'; 14 | done 15 | 16 | echo '' 17 | -------------------------------------------------------------------------------- /tests/apis/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from elastic_workplace_search import Client 3 | 4 | 5 | @pytest.fixture(scope="session") 6 | def vcr_config(): 7 | return {"filter_headers": ["user-agent"]} 8 | 9 | 10 | @pytest.fixture(scope="session") 11 | def client(): 12 | return Client( 13 | authorization_token=( 14 | "32744aeb04f1269f57376347ee1d8f4e915e8273bfa9b2036aff4ef770bd2377" 15 | ), 16 | base_url="http://localhost:8080/api/ws/v1", 17 | ) 18 | 19 | 20 | @pytest.fixture(scope="session") 21 | def content_source_key(client): 22 | return "5eebbb1e5e21d6c1e64f9578" 23 | -------------------------------------------------------------------------------- /elastic_workplace_search/client.py: -------------------------------------------------------------------------------- 1 | from .request_session import RequestSession 2 | from .apis.documents import Documents 3 | from .apis.permissions import Permissions 4 | 5 | """API client for Elastic Workplace Search""" 6 | 7 | 8 | class Client: 9 | 10 | ELASTIC_WORKPLACE_SEARCH_BASE_URL = "http://localhost:3002/api/ws/v1" 11 | 12 | def __init__(self, authorization_token, base_url=ELASTIC_WORKPLACE_SEARCH_BASE_URL): 13 | self.authorization_token = authorization_token 14 | self.base_url = base_url 15 | self.session = RequestSession(self.authorization_token, self.base_url) 16 | 17 | self.documents = Documents(self.session) 18 | self.permissions = Permissions(self.session) 19 | -------------------------------------------------------------------------------- /elastic_workplace_search/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions raised by Elastic Workplace Search Client.""" 2 | 3 | 4 | class WorkplaceSearchError(Exception): 5 | """Base class for all Workplace Search errors.""" 6 | 7 | 8 | class InvalidCredentials(WorkplaceSearchError): 9 | """Raised when request cannot authenticate""" 10 | 11 | 12 | class NonExistentRecord(WorkplaceSearchError): 13 | """Raised when record does not exist""" 14 | 15 | 16 | class RecordAlreadyExists(WorkplaceSearchError): 17 | """Raised when record already exists""" 18 | 19 | 20 | class BadRequest(WorkplaceSearchError): 21 | """Raised when bad request""" 22 | 23 | 24 | class Forbidden(WorkplaceSearchError): 25 | """Raised when http forbidden""" 26 | -------------------------------------------------------------------------------- /.ci/README.md: -------------------------------------------------------------------------------- 1 | # CI 2 | 3 | ## Local Development 4 | 5 | You can currently get Enterprise Search running locally with docker-compose, 6 | run `docker-compose up` from this directory to get everything set up. 7 | You need to set the value of `STACK_VERSION` in your environment: 8 | 9 | ```bash 10 | $ STACK_VERSION=7.7.0 docker-compose up 11 | ``` 12 | 13 | You can check it's working with: 14 | ```bash 15 | curl http://localhost:8080/swiftype-app-version 16 | ``` 17 | 18 | or you can start everything and wait for it to become available 19 | with one bash script: 20 | 21 | ```bash 22 | $ STACK_VERSION=7.7.0 .ci/start-stack.sh 23 | ``` 24 | 25 | and stop everything when you're done: 26 | 27 | ```bash 28 | $ STACK_VERSION=7.7.0 .ci/stop-stack.sh 29 | ``` 30 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | import nox 2 | 3 | 4 | SOURCE_FILES = ( 5 | "setup.py", 6 | "noxfile.py", 7 | "elastic_workplace_search/", 8 | "tests/", 9 | ) 10 | 11 | 12 | @nox.session(python=["2.7", "3.4", "3.5", "3.6", "3.7", "3.8"]) 13 | def test(session): 14 | session.install(".") 15 | session.install("-r", "dev-requirements.txt") 16 | 17 | session.run("pytest", "--record-mode=none", "tests/") 18 | 19 | 20 | @nox.session() 21 | def blacken(session): 22 | session.install("black") 23 | session.run("black", *SOURCE_FILES) 24 | 25 | lint(session) 26 | 27 | 28 | @nox.session() 29 | def lint(session): 30 | session.install("flake8", "black") 31 | session.run("black", "--check", *SOURCE_FILES) 32 | session.run("flake8", "--select=E,W,F", "--max-line-length=88", *SOURCE_FILES) 33 | -------------------------------------------------------------------------------- /elastic_workplace_search/utils.py: -------------------------------------------------------------------------------- 1 | import signal 2 | import platform 3 | from functools import wraps 4 | 5 | 6 | class Timeout: 7 | def __init__(self, exception_class, seconds=1, error_message="Timeout"): 8 | self.exception_class = exception_class 9 | self.seconds = seconds 10 | self.error_message = error_message 11 | 12 | def handle_timeout(self, signum, frame): 13 | raise self.exception_class(self.error_message) 14 | 15 | def __enter__(self): 16 | signal.signal(signal.SIGALRM, self.handle_timeout) 17 | signal.alarm(self.seconds) 18 | 19 | def __exit__(self, type, value, traceback): 20 | signal.alarm(0) 21 | 22 | 23 | def windows_incompatible(error_message=None): 24 | error_message = error_message or "This function is not supported on Windows." 25 | 26 | def decorator(f): 27 | @wraps(f) 28 | def decorated(*args, **kwargs): 29 | if platform.system() == "Windows": 30 | raise OSError(error_message) 31 | return f(*args, **kwargs) 32 | 33 | return decorated 34 | 35 | return decorator 36 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.3.0 (2020-02-11) 4 | 5 | - This release is compatible with Workplace Search v7.6 6 | - Changed the package name to `elastic-workplace-search` to follow the product 7 | name change ([Announcement](https://www.elastic.co/blog/elastic-enterprise-search-updates-for-7-6-0)) 8 | - Changed API path from `/api/v1/ent/` to `/api/ws/v1/`. If previously 9 | using a custom API endpoint you may need to update accordingly. 10 | 11 | ## 0.2.0 (2019-10-17) 12 | 13 | - Changed the `index_documents` and `delete_documents` 14 | API methods to be namespaced under `client.documents`. 15 | API calls will need to be updated accordingly. 16 | - Added support for the Permissions API ([#20](https://github.com/elastic/workplace-search-python/pull/20)) 17 | 18 | ## 0.1.0 (2019-08-19) 19 | 20 | - Changed "Swiftype" references to "Elastic" in the README and code 21 | - Changed the package name to `elastic-enterprise-search` 22 | - Changed versioning to be pre-release again (0.1.0) since we are not yet GA 23 | similar to App Search 24 | - Added analytics HTTP headers `Swiftype-X-Client` and `Swiftype-X-Client-Version` 25 | - Added Circle CI 26 | -------------------------------------------------------------------------------- /tests/apis/cassettes/test_permissions/TestPermissions.test_get_user_permissions.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - Bearer 32744aeb04f1269f57376347ee1d8f4e915e8273bfa9b2036aff4ef770bd2377 11 | Connection: 12 | - keep-alive 13 | method: GET 14 | uri: http://localhost:8080/api/ws/v1/sources/5eebbb1e5e21d6c1e64f9578/permissions/enterprise_search 15 | response: 16 | body: 17 | string: '{"user":"enterprise_search","permissions":["permission1"]}' 18 | headers: 19 | Cache-Control: 20 | - max-age=0, private, must-revalidate 21 | Content-Type: 22 | - application/json; charset=utf-8 23 | ETag: 24 | - W/"6cb0d74ff26d6f74253df3ee283a38dd" 25 | Server: 26 | - Jetty(9.2.29.v20191105) 27 | Transfer-Encoding: 28 | - chunked 29 | X-Content-Type-Options: 30 | - nosniff 31 | X-Frame-Options: 32 | - SAMEORIGIN 33 | X-Request-Id: 34 | - bd1e3517-4395-4399-980f-b8dcfd883491 35 | X-Runtime: 36 | - '0.040057' 37 | X-XSS-Protection: 38 | - 1; mode=block 39 | status: 40 | code: 200 41 | message: OK 42 | version: 1 43 | -------------------------------------------------------------------------------- /elastic_workplace_search/apis/permissions.py: -------------------------------------------------------------------------------- 1 | class Permissions: 2 | def __init__(self, session): 3 | self.session = session 4 | 5 | def list_all_permissions(self, content_source_key, current=1, size=25): 6 | endpoint = "sources/{}/permissions".format(content_source_key) 7 | params = {"page[current]": current, "page[size]": size} 8 | return self.session.request("get", endpoint, params=params) 9 | 10 | def get_user_permissions(self, content_source_key, user): 11 | endpoint = "sources/{}/permissions/{}".format(content_source_key, user) 12 | return self.session.request("get", endpoint) 13 | 14 | def update_user_permissions(self, content_source_key, user, options): 15 | endpoint = "sources/{}/permissions/{}".format(content_source_key, user) 16 | return self.session.request("post", endpoint, json=options) 17 | 18 | def add_user_permissions(self, content_source_key, user, options): 19 | endpoint = "sources/{}/permissions/{}/add".format(content_source_key, user) 20 | return self.session.request("post", endpoint, json=options) 21 | 22 | def remove_user_permissions(self, content_source_key, user, options): 23 | endpoint = "sources/{}/permissions/{}/remove".format(content_source_key, user) 24 | return self.session.request("post", endpoint, json=options) 25 | -------------------------------------------------------------------------------- /tests/apis/cassettes/test_permissions/TestPermissions.test_list_all_permissions_with_paging.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - Bearer 32744aeb04f1269f57376347ee1d8f4e915e8273bfa9b2036aff4ef770bd2377 11 | Connection: 12 | - keep-alive 13 | method: GET 14 | uri: http://localhost:8080/api/ws/v1/sources/5eebbb1e5e21d6c1e64f9578/permissions?page%5Bcurrent%5D=2&page%5Bsize%5D=20 15 | response: 16 | body: 17 | string: '{"meta":{"page":{"current":2,"total_pages":1,"total_results":1,"size":20}},"results":[]}' 18 | headers: 19 | Cache-Control: 20 | - max-age=0, private, must-revalidate 21 | Content-Type: 22 | - application/json; charset=utf-8 23 | ETag: 24 | - W/"1b970cecdea704c6fa9ccfa2a27c8ca9" 25 | Server: 26 | - Jetty(9.2.29.v20191105) 27 | Transfer-Encoding: 28 | - chunked 29 | X-Content-Type-Options: 30 | - nosniff 31 | X-Frame-Options: 32 | - SAMEORIGIN 33 | X-Request-Id: 34 | - 8c31388d-eebb-4ed6-bc2b-caccf5cbdf79 35 | X-Runtime: 36 | - '0.039516' 37 | X-XSS-Protection: 38 | - 1; mode=block 39 | status: 40 | code: 200 41 | message: OK 42 | version: 1 43 | -------------------------------------------------------------------------------- /tests/apis/cassettes/test_documents/TestDocuments.test_delete_documents.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: '[1, "2"]' 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - Bearer 32744aeb04f1269f57376347ee1d8f4e915e8273bfa9b2036aff4ef770bd2377 11 | Connection: 12 | - keep-alive 13 | Content-Length: 14 | - '8' 15 | Content-Type: 16 | - application/json 17 | method: POST 18 | uri: http://localhost:8080/api/ws/v1/sources/5eebbb1e5e21d6c1e64f9578/documents/bulk_destroy 19 | response: 20 | body: 21 | string: '{"results":[{"id":1,"success":true},{"id":"2","success":true}]}' 22 | headers: 23 | Cache-Control: 24 | - max-age=0, private, must-revalidate 25 | Content-Type: 26 | - application/json; charset=utf-8 27 | ETag: 28 | - W/"77b44f941fd70bd4db8769a374c4408d" 29 | Server: 30 | - Jetty(9.2.29.v20191105) 31 | Transfer-Encoding: 32 | - chunked 33 | X-Content-Type-Options: 34 | - nosniff 35 | X-Frame-Options: 36 | - SAMEORIGIN 37 | X-Request-Id: 38 | - 95ce13bc-5e1d-46f1-aaca-6858d1d310c6 39 | X-Runtime: 40 | - '0.164300' 41 | X-XSS-Protection: 42 | - 1; mode=block 43 | status: 44 | code: 200 45 | message: OK 46 | version: 1 47 | -------------------------------------------------------------------------------- /tests/apis/cassettes/test_permissions/TestPermissions.test_list_all_permissions.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - Bearer 32744aeb04f1269f57376347ee1d8f4e915e8273bfa9b2036aff4ef770bd2377 11 | Connection: 12 | - keep-alive 13 | method: GET 14 | uri: http://localhost:8080/api/ws/v1/sources/5eebbb1e5e21d6c1e64f9578/permissions?page%5Bcurrent%5D=1&page%5Bsize%5D=25 15 | response: 16 | body: 17 | string: '{"meta":{"page":{"current":1,"total_pages":1,"total_results":1,"size":25}},"results":[{"user":"enterprise_search","permissions":["permission1"]}]}' 18 | headers: 19 | Cache-Control: 20 | - max-age=0, private, must-revalidate 21 | Content-Type: 22 | - application/json; charset=utf-8 23 | ETag: 24 | - W/"21372cc57fbf7b6f6d19643937e4eaaf" 25 | Server: 26 | - Jetty(9.2.29.v20191105) 27 | Transfer-Encoding: 28 | - chunked 29 | X-Content-Type-Options: 30 | - nosniff 31 | X-Frame-Options: 32 | - SAMEORIGIN 33 | X-Request-Id: 34 | - 7225d807-7a02-4322-a48a-6c05669c3eb6 35 | X-Runtime: 36 | - '0.041343' 37 | X-XSS-Protection: 38 | - 1; mode=block 39 | status: 40 | code: 200 41 | message: OK 42 | version: 1 43 | -------------------------------------------------------------------------------- /tests/apis/cassettes/test_documents/TestDocuments.test_index_documents_source_not_found.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: '[1, 2]' 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - Bearer 32744aeb04f1269f57376347ee1d8f4e915e8273bfa9b2036aff4ef770bd2377 11 | Connection: 12 | - keep-alive 13 | Content-Length: 14 | - '6' 15 | Content-Type: 16 | - application/json 17 | method: POST 18 | uri: http://localhost:8080/api/ws/v1/sources/bad-source-key/documents/bulk_create 19 | response: 20 | body: 21 | string: '' 22 | headers: 23 | Cache-Control: 24 | - no-cache 25 | Content-Security-Policy: 26 | - script-src 'nonce-e+nsspLfDiVaIGU5HixHxA==' 'strict-dynamic' 'self'; object-src 27 | 'none'; base-uri 'none'; frame-ancestors 'self'; 28 | Content-Type: 29 | - text/html 30 | Server: 31 | - Jetty(9.2.29.v20191105) 32 | Transfer-Encoding: 33 | - chunked 34 | X-Content-Type-Options: 35 | - nosniff 36 | X-Frame-Options: 37 | - SAMEORIGIN 38 | X-Request-Id: 39 | - 81b28e5a-56c3-46e3-820a-8e6238361ca6 40 | X-Runtime: 41 | - '0.036074' 42 | X-XSS-Protection: 43 | - 1; mode=block 44 | status: 45 | code: 404 46 | message: Not Found 47 | version: 1 48 | -------------------------------------------------------------------------------- /tests/apis/cassettes/test_documents/TestDocuments.test_delete_documents_source_not_found.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: '[1, 2]' 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - Bearer 32744aeb04f1269f57376347ee1d8f4e915e8273bfa9b2036aff4ef770bd2377 11 | Connection: 12 | - keep-alive 13 | Content-Length: 14 | - '6' 15 | Content-Type: 16 | - application/json 17 | method: POST 18 | uri: http://localhost:8080/api/ws/v1/sources/bad-source-key/documents/bulk_destroy 19 | response: 20 | body: 21 | string: '' 22 | headers: 23 | Cache-Control: 24 | - no-cache 25 | Content-Security-Policy: 26 | - script-src 'nonce-L6XvPcXkNl85q8Wwyr0rVw==' 'strict-dynamic' 'self'; object-src 27 | 'none'; base-uri 'none'; frame-ancestors 'self'; 28 | Content-Type: 29 | - text/html 30 | Server: 31 | - Jetty(9.2.29.v20191105) 32 | Transfer-Encoding: 33 | - chunked 34 | X-Content-Type-Options: 35 | - nosniff 36 | X-Frame-Options: 37 | - SAMEORIGIN 38 | X-Request-Id: 39 | - 3e12a641-e82d-4639-8816-8dfa123d2200 40 | X-Runtime: 41 | - '0.020764' 42 | X-XSS-Protection: 43 | - 1; mode=block 44 | status: 45 | code: 404 46 | message: Not Found 47 | version: 1 48 | -------------------------------------------------------------------------------- /tests/apis/cassettes/test_permissions/TestPermissions.test_add_user_permissions.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: '{"permissions": ["permission1"]}' 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - Bearer 32744aeb04f1269f57376347ee1d8f4e915e8273bfa9b2036aff4ef770bd2377 11 | Connection: 12 | - keep-alive 13 | Content-Length: 14 | - '32' 15 | Content-Type: 16 | - application/json 17 | method: POST 18 | uri: http://localhost:8080/api/ws/v1/sources/5eebbb1e5e21d6c1e64f9578/permissions/enterprise_search/add 19 | response: 20 | body: 21 | string: '{"user":"enterprise_search","permissions":["permission1"]}' 22 | headers: 23 | Cache-Control: 24 | - max-age=0, private, must-revalidate 25 | Content-Type: 26 | - application/json; charset=utf-8 27 | ETag: 28 | - W/"6cb0d74ff26d6f74253df3ee283a38dd" 29 | Server: 30 | - Jetty(9.2.29.v20191105) 31 | Transfer-Encoding: 32 | - chunked 33 | X-Content-Type-Options: 34 | - nosniff 35 | X-Frame-Options: 36 | - SAMEORIGIN 37 | X-Request-Id: 38 | - 6cee13b3-2f2c-4983-9b35-cbe1825b9043 39 | X-Runtime: 40 | - '0.061162' 41 | X-XSS-Protection: 42 | - 1; mode=block 43 | status: 44 | code: 200 45 | message: OK 46 | version: 1 47 | -------------------------------------------------------------------------------- /tests/apis/test_documents.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from elastic_workplace_search.exceptions import NonExistentRecord 3 | 4 | 5 | class TestDocuments: 6 | @pytest.mark.vcr 7 | def test_index_documents(self, client, content_source_key): 8 | documents = [ 9 | {"id": 1, "url": "", "title": "", "body": ""}, 10 | {"id": "2", "url": "", "title": "", "body": ""}, 11 | ] 12 | 13 | resp = client.documents.index_documents(content_source_key, documents) 14 | assert list(resp) == ["results"] 15 | assert resp["results"] == [{"errors": [], "id": "1"}, {"errors": [], "id": "2"}] 16 | 17 | @pytest.mark.vcr 18 | def test_delete_documents(self, client, content_source_key): 19 | resp = client.documents.delete_documents(content_source_key, [1, "2"]) 20 | assert list(resp) == ["results"] 21 | assert resp["results"] == [ 22 | {"success": True, "id": 1}, 23 | {"success": True, "id": "2"}, 24 | ] 25 | 26 | @pytest.mark.vcr 27 | def test_index_documents_source_not_found(self, client): 28 | with pytest.raises(NonExistentRecord): 29 | client.documents.index_documents("bad-source-key", [1, 2]) 30 | 31 | @pytest.mark.vcr 32 | def test_delete_documents_source_not_found(self, client): 33 | with pytest.raises(NonExistentRecord): 34 | client.documents.delete_documents("bad-source-key", [1, 2]) 35 | -------------------------------------------------------------------------------- /tests/apis/cassettes/test_permissions/TestPermissions.test_remove_user_permissions.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: '{"permissions": ["permission2"]}' 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - Bearer 32744aeb04f1269f57376347ee1d8f4e915e8273bfa9b2036aff4ef770bd2377 11 | Connection: 12 | - keep-alive 13 | Content-Length: 14 | - '32' 15 | Content-Type: 16 | - application/json 17 | method: POST 18 | uri: http://localhost:8080/api/ws/v1/sources/5eebbb1e5e21d6c1e64f9578/permissions/enterprise_search/remove 19 | response: 20 | body: 21 | string: '{"user":"enterprise_search","permissions":["permission1"]}' 22 | headers: 23 | Cache-Control: 24 | - max-age=0, private, must-revalidate 25 | Content-Type: 26 | - application/json; charset=utf-8 27 | ETag: 28 | - W/"6cb0d74ff26d6f74253df3ee283a38dd" 29 | Server: 30 | - Jetty(9.2.29.v20191105) 31 | Transfer-Encoding: 32 | - chunked 33 | X-Content-Type-Options: 34 | - nosniff 35 | X-Frame-Options: 36 | - SAMEORIGIN 37 | X-Request-Id: 38 | - 77de7cbd-ede3-4074-9517-eb09330bc0bc 39 | X-Runtime: 40 | - '0.098598' 41 | X-XSS-Protection: 42 | - 1; mode=block 43 | status: 44 | code: 200 45 | message: OK 46 | version: 1 47 | -------------------------------------------------------------------------------- /tests/apis/cassettes/test_permissions/TestPermissions.test_update_user_permissions.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: '{"permissions": ["permission1", "permission2"]}' 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Authorization: 10 | - Bearer 32744aeb04f1269f57376347ee1d8f4e915e8273bfa9b2036aff4ef770bd2377 11 | Connection: 12 | - keep-alive 13 | Content-Length: 14 | - '47' 15 | Content-Type: 16 | - application/json 17 | method: POST 18 | uri: http://localhost:8080/api/ws/v1/sources/5eebbb1e5e21d6c1e64f9578/permissions/enterprise_search 19 | response: 20 | body: 21 | string: '{"user":"enterprise_search","permissions":["permission1","permission2"]}' 22 | headers: 23 | Cache-Control: 24 | - max-age=0, private, must-revalidate 25 | Content-Type: 26 | - application/json; charset=utf-8 27 | ETag: 28 | - W/"b468c7da0c319e7f5f76f1b107306a23" 29 | Server: 30 | - Jetty(9.2.29.v20191105) 31 | Transfer-Encoding: 32 | - chunked 33 | X-Content-Type-Options: 34 | - nosniff 35 | X-Frame-Options: 36 | - SAMEORIGIN 37 | X-Request-Id: 38 | - 122e5566-6959-4680-aa04-2739aa4dec14 39 | X-Runtime: 40 | - '0.076391' 41 | X-XSS-Protection: 42 | - 1; mode=block 43 | status: 44 | code: 200 45 | message: OK 46 | version: 1 47 | -------------------------------------------------------------------------------- /tests/apis/cassettes/test_documents/TestDocuments.test_index_documents.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: '[{"id": 1, "url": "", "title": "", "body": ""}, {"id": "2", "url": "", 4 | "title": "", "body": ""}]' 5 | headers: 6 | Accept: 7 | - '*/*' 8 | Accept-Encoding: 9 | - gzip, deflate 10 | Authorization: 11 | - Bearer 32744aeb04f1269f57376347ee1d8f4e915e8273bfa9b2036aff4ef770bd2377 12 | Connection: 13 | - keep-alive 14 | Content-Length: 15 | - '96' 16 | Content-Type: 17 | - application/json 18 | method: POST 19 | uri: http://localhost:8080/api/ws/v1/sources/5eebbb1e5e21d6c1e64f9578/documents/bulk_create 20 | response: 21 | body: 22 | string: '{"results":[{"id":"1","errors":[]},{"id":"2","errors":[]}]}' 23 | headers: 24 | Cache-Control: 25 | - max-age=0, private, must-revalidate 26 | Content-Type: 27 | - application/json; charset=utf-8 28 | ETag: 29 | - W/"524357b5bfe8f3cf4539eab2daa660ff" 30 | Server: 31 | - Jetty(9.2.29.v20191105) 32 | Transfer-Encoding: 33 | - chunked 34 | X-Content-Type-Options: 35 | - nosniff 36 | X-Frame-Options: 37 | - SAMEORIGIN 38 | X-Request-Id: 39 | - 279c7794-35e5-47ad-a5e0-3228fd8e031d 40 | X-Runtime: 41 | - '0.147254' 42 | X-XSS-Protection: 43 | - 1; mode=block 44 | status: 45 | code: 200 46 | message: OK 47 | version: 1 48 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | lint: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 9 | - uses: actions/setup-python@v2 10 | with: 11 | python-version: 3.7 12 | - name: Lint 13 | run: | 14 | python3.7 -m pip install nox 15 | nox -s lint 16 | 17 | test: 18 | env: 19 | ENDPOINT: http://localhost:8080/api/ws/v1 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | python-version: [2.7, 3.5, 3.6, 3.7, 3.8] 24 | 25 | runs-on: ubuntu-latest 26 | steps: 27 | # Checkout and Setup Python 28 | - uses: actions/checkout@v2 29 | - uses: actions/setup-python@v1 30 | with: 31 | python-version: 3.7 32 | - if: matrix.python-version != '3.7' 33 | uses: actions/setup-python@v1 34 | with: 35 | python-version: ${{ matrix.python-version }} 36 | 37 | # Setup Enterprise Search 38 | - name: Configure sysctl limits 39 | run: | 40 | sudo swapoff -a 41 | sudo sysctl -w vm.swappiness=1 42 | sudo sysctl -w fs.file-max=262144 43 | sudo sysctl -w vm.max_map_count=262144 44 | - name: Run Enterprise Search 45 | uses: elastic/elastic-github-actions/enterprise-search@master 46 | with: 47 | stack-version: 7.7.0 48 | 49 | # Run tests 50 | - name: Test 51 | run: | 52 | python3.7 -m pip install nox 53 | nox -s test-${{ matrix.python-version }} 54 | -------------------------------------------------------------------------------- /elastic_workplace_search/request_session.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import elastic_workplace_search 3 | from .exceptions import ( 4 | InvalidCredentials, 5 | NonExistentRecord, 6 | RecordAlreadyExists, 7 | BadRequest, 8 | Forbidden, 9 | ) 10 | 11 | 12 | class RequestSession: 13 | def __init__(self, authorization_token, base_url): 14 | self.authorization_token = authorization_token 15 | self.base_url = base_url 16 | self.session = requests.Session() 17 | 18 | headers = { 19 | "Authorization": "Bearer {}".format(self.authorization_token), 20 | "X-Swiftype-Client": "elastic-workplace-search-python", 21 | "X-Swiftype-Client-Version": elastic_workplace_search.__version__, 22 | } 23 | self.session.headers.update(headers) 24 | 25 | def raise_if_error(self, response): 26 | if response.status_code == requests.codes.unauthorized: 27 | raise InvalidCredentials(response.reason) 28 | elif response.status_code == requests.codes.bad: 29 | raise BadRequest() 30 | elif response.status_code == requests.codes.conflict: 31 | raise RecordAlreadyExists() 32 | elif response.status_code == requests.codes.not_found: 33 | raise NonExistentRecord() 34 | elif response.status_code == requests.codes.forbidden: 35 | raise Forbidden() 36 | 37 | response.raise_for_status() 38 | 39 | def request(self, http_method, endpoint, **kwargs): 40 | url = "{}/{}".format(self.base_url, endpoint) 41 | response = self.session.request(http_method, url, **kwargs) 42 | self.raise_if_error(response) 43 | return response.json() 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # Jupyter Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # SageMath parsed files 79 | *.sage.py 80 | 81 | # Environments 82 | .env 83 | .venv 84 | env/ 85 | venv/ 86 | ENV/ 87 | 88 | # Spyder project settings 89 | .spyderproject 90 | .spyproject 91 | 92 | # Rope project settings 93 | .ropeproject 94 | 95 | # mkdocs documentation 96 | /site 97 | 98 | # mypy 99 | .mypy_cache/ 100 | 101 | doc/_* 102 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from codecs import open 3 | from os import path 4 | 5 | base_dir = path.abspath(path.dirname(__file__)) 6 | 7 | # Get the long description from the README file 8 | with open(path.join(base_dir, "README.md"), encoding="utf-8") as f: 9 | long_description = f.read() 10 | 11 | about = {} 12 | with open( 13 | path.join(base_dir, "elastic_workplace_search/__version__.py"), encoding="utf-8" 14 | ) as f: 15 | exec(f.read(), about) 16 | 17 | setup( 18 | name=about["__title__"], 19 | version=about["__version__"], 20 | description=about["__description__"], 21 | long_description=long_description, 22 | long_description_content_type="text/markdown", 23 | url=about["__url__"], 24 | author=about["__author__"], 25 | author_email=about["__author_email__"], 26 | maintainer=about["__maintainer__"], 27 | maintainer_email=about["__maintainer_email__"], 28 | license="Apache-2.0", 29 | classifiers=[ 30 | "Development Status :: 4 - Beta", 31 | "Intended Audience :: Developers", 32 | "License :: OSI Approved :: Apache Software License", 33 | "Programming Language :: Python :: 2", 34 | "Programming Language :: Python :: 2.7", 35 | "Programming Language :: Python :: 3", 36 | "Programming Language :: Python :: 3.4", 37 | "Programming Language :: Python :: 3.5", 38 | "Programming Language :: Python :: 3.6", 39 | "Programming Language :: Python :: 3.7", 40 | "Programming Language :: Python :: 3.8", 41 | ], 42 | keywords="elastic workplace search api", 43 | packages=find_packages(exclude=["tests"]), 44 | install_requires=["requests"], 45 | python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4", 46 | ) 47 | -------------------------------------------------------------------------------- /.ci/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.1' 2 | 3 | services: 4 | elasticsearch: 5 | image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION:-7.x-SNAPSHOT} 6 | environment: 7 | - "node.name=es-node" 8 | - "discovery.type=single-node" 9 | - "cluster.name=ent-search-docker-cluster" 10 | - "bootstrap.memory_lock=true" 11 | - "ES_JAVA_OPTS=-Xms512m -Xmx512m" 12 | - "xpack.license.self_generated.type=trial" 13 | - "xpack.security.enabled=true" 14 | - "xpack.security.authc.api_key.enabled=true" 15 | - "ELASTIC_PASSWORD=changeme" 16 | - "action.auto_create_index=.ent-search-*-logs-*,-.ent-search-*,+*" 17 | ulimits: 18 | memlock: 19 | soft: -1 20 | hard: -1 21 | ports: 22 | - 9200:9200 23 | 24 | #------------------------------------------------------------------------------------------------- 25 | enterprise-search: 26 | image: docker.elastic.co/enterprise-search/enterprise-search:${STACK_VERSION:?missing revision for enterprise search} 27 | environment: 28 | - "ENT_SEARCH_DEFAULT_PASSWORD=itsnotcloudsearch" 29 | - "ent_search.listen_port=8080" 30 | - "ent_search.external_url=http://localhost:8080" 31 | - "ent_search.auth.source=standard" 32 | - "elasticsearch.host=http://elasticsearch:9200" 33 | - "allow_es_settings_modification=true" 34 | - "elasticsearch.username=elastic" 35 | - "elasticsearch.password=changeme" 36 | - "secret_management.encryption_keys=['testtesttest']" 37 | ports: 38 | - 8080:8080 39 | - 8081:8081 40 | depends_on: 41 | - elasticsearch 42 | entrypoint: /bin/bash -c "until curl -s -f -u elastic:changeme elasticsearch:9200/_license|grep -q trial; do sleep 2; done; /usr/local/bin/docker-entrypoint.sh" 43 | -------------------------------------------------------------------------------- /tests/test_request_session.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from requests.status_codes import codes 3 | from mock import MagicMock, patch 4 | 5 | import elastic_workplace_search 6 | from elastic_workplace_search.request_session import RequestSession 7 | from elastic_workplace_search.exceptions import InvalidCredentials 8 | 9 | 10 | class TestRequestSession: 11 | 12 | dummy_authorization_token = "authorization_token" 13 | 14 | def test_request_success(self): 15 | http = RequestSession(self.dummy_authorization_token, "base_url") 16 | 17 | expected_return = {"foo": "bar"} 18 | stubbed_return = MagicMock(status_code=codes.ok, json=lambda: expected_return) 19 | with patch("requests.Session.request", return_value=stubbed_return): 20 | resp = http.request("post", "http://doesnt.matter.org") 21 | 22 | assert resp == expected_return 23 | 24 | def test_headers_initialization(self): 25 | http = RequestSession(self.dummy_authorization_token, "base_url") 26 | headers = dict(http.session.headers.items()) 27 | version = elastic_workplace_search.__version__ 28 | 29 | assert headers == { 30 | "Accept": "*/*", 31 | "Accept-Encoding": "gzip, deflate", 32 | "Authorization": "Bearer authorization_token", 33 | "Connection": "keep-alive", 34 | "User-Agent": "python-requests/2.24.0", 35 | "X-Swiftype-Client": "elastic-workplace-search-python", 36 | "X-Swiftype-Client-Version": version, 37 | } 38 | 39 | def test_request_throw_error(self): 40 | http = RequestSession(self.dummy_authorization_token, "base_url") 41 | stubbed_return = MagicMock(status_code=codes.unauthorized) 42 | 43 | with patch("requests.Session.request", return_value=stubbed_return): 44 | with pytest.raises(InvalidCredentials): 45 | http.request("post", "http://doesnt.matter.org") 46 | -------------------------------------------------------------------------------- /tests/apis/test_permissions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | class TestPermissions: 5 | @pytest.mark.vcr 6 | def test_list_all_permissions(self, client, content_source_key): 7 | resp = client.permissions.list_all_permissions(content_source_key) 8 | assert resp == { 9 | "meta": { 10 | "page": {"current": 1, "total_pages": 1, "total_results": 1, "size": 25} 11 | }, 12 | "results": [{"permissions": ["permission1"], "user": "enterprise_search"}], 13 | } 14 | 15 | @pytest.mark.vcr 16 | def test_list_all_permissions_with_paging(self, client, content_source_key): 17 | resp = client.permissions.list_all_permissions( 18 | content_source_key, current=2, size=20 19 | ) 20 | assert resp == { 21 | "meta": { 22 | "page": {"current": 2, "total_pages": 1, "total_results": 1, "size": 20} 23 | }, 24 | "results": [], 25 | } 26 | 27 | @pytest.mark.vcr 28 | def test_add_user_permissions(self, client, content_source_key): 29 | resp = client.permissions.add_user_permissions( 30 | content_source_key, 31 | user="enterprise_search", 32 | options={"permissions": ["permission1"]}, 33 | ) 34 | assert resp == {"user": "enterprise_search", "permissions": ["permission1"]} 35 | 36 | @pytest.mark.vcr 37 | def test_get_user_permissions(self, client, content_source_key): 38 | resp = client.permissions.get_user_permissions( 39 | content_source_key, user="enterprise_search" 40 | ) 41 | assert resp == {"user": "enterprise_search", "permissions": ["permission1"]} 42 | 43 | @pytest.mark.vcr 44 | def test_update_user_permissions(self, client, content_source_key): 45 | resp = client.permissions.update_user_permissions( 46 | content_source_key, 47 | user="enterprise_search", 48 | options={"permissions": ["permission1", "permission2"]}, 49 | ) 50 | assert resp == { 51 | "user": "enterprise_search", 52 | "permissions": ["permission1", "permission2"], 53 | } 54 | 55 | @pytest.mark.vcr 56 | def test_remove_user_permissions(self, client, content_source_key): 57 | resp = client.permissions.remove_user_permissions( 58 | content_source_key, 59 | user="enterprise_search", 60 | options={"permissions": ["permission2"]}, 61 | ) 62 | assert resp == {"permissions": ["permission1"], "user": "enterprise_search"} 63 | -------------------------------------------------------------------------------- /elastic_workplace_search/apis/documents.py: -------------------------------------------------------------------------------- 1 | class Documents: 2 | def __init__(self, session): 3 | self.session = session 4 | 5 | def index_documents(self, content_source_key, documents, **kwargs): 6 | """Index a batch of documents in a content source. 7 | Raises :class:`~elastic_workplace_search.NonExistentRecord` if the 8 | content_source_key is malformed or invalid. Raises 9 | :class:`~elastic_workplace_search.WorkplaceSearchError` if there are any 10 | HTTP errors. 11 | 12 | :param content_source_key: Key for the content source. 13 | :param documents: Array of documents to be indexed. 14 | :return: Array of document indexing results. 15 | 16 | >>> from elastic_workplace_search import Client 17 | >>> from elastic_workplace_search.exceptions import WorkplaceSearchError 18 | >>> content_source_key = 'content source key' 19 | >>> authorization_token = 'authorization token' 20 | >>> client = Client(authorization_token) 21 | >>> documents = [ 22 | { 23 | 'id': '1', 24 | 'url': 'https://github.com/elastic/workplace-search-python', 25 | 'title': 'Elastic Workplace Search Official Python client', 26 | 'body': 'A descriptive body' 27 | } 28 | ] 29 | >>> try: 30 | >>> document_results = client.documents.index_documents( 31 | ... content_source_key, documents 32 | ... ) 33 | >>> print(document_results) 34 | >>> except WorkplaceSearchError: 35 | >>> # handle exception 36 | >>> pass 37 | [{'errors': [], 'id': '1', 'id': None}] 38 | """ 39 | return self._async_create_or_update_documents(content_source_key, documents) 40 | 41 | def delete_documents(self, content_source_key, ids): 42 | """Destroys documents in a content source by their ids. 43 | Raises :class:`~elastic_workplace_search.NonExistentRecord` if the 44 | content_source_key is malformed or invalid. Raises 45 | :class:`~elastic_workplace_search.WorkplaceSearchError` if there are any 46 | HTTP errors. 47 | 48 | :param content_source_key: Key for the content source. 49 | :param ids: Array of document ids to be destroyed. 50 | :return: Array of result dicts, with keys of `id` and `status` 51 | 52 | >>> from elastic_workplace_search import Client 53 | >>> from elastic_workplace_search.exceptions import WorkplaceSearchError 54 | >>> content_source_key = 'content source key' 55 | >>> authorization_token = 'authorization token' 56 | >>> client = Client(authorization_token) 57 | >>> try: 58 | >>> response = client.documents.delete_documents(content_source_key, ['1']) 59 | >>> print(response) 60 | >>> except WorkplaceSearchError: 61 | >>> # handle exception 62 | >>> pass 63 | [{"id": '1',"success": True}] 64 | """ 65 | endpoint = "sources/{}/documents/bulk_destroy".format(content_source_key) 66 | return self.session.request("post", endpoint, json=ids) 67 | 68 | def _async_create_or_update_documents(self, content_source_key, documents): 69 | endpoint = "sources/{}/documents/bulk_create".format(content_source_key) 70 | return self.session.request("post", endpoint, json=documents) 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > **⚠️ This client is deprecated ⚠️** 2 | > 3 | > As of Enterprise Search version 7.10.0, we are directing users to the new [Enterprise Search Python Client](https://github.com/elastic/enterprise-search-python) and 4 | > deprecating this client. 5 | > 6 | > This client will be compatible with all Enterprise Search 7.x releases, but will not be compatible with 8.x releases. Our development effort on this project will 7 | > be limited to bug fixes. All future enhancements will be focused on the Enterprise Search Python Client. 8 | > 9 | > Thank you! - Elastic 10 | 11 |
12 |
13 | > A first-party Python client for [Elastic Workplace Search](https://www.elastic.co/workplace-search).
14 |
15 | ## Contents
16 |
17 | + [Getting started](#getting-started-)
18 | + [Usage](#usage)
19 | + [FAQ](#faq-)
20 | + [Contribute](#contribute-)
21 | + [License](#license-)
22 |
23 | ***
24 |
25 | ## Getting started 🐣
26 |
27 | Supports Python 2.7 and Python 3.4+.
28 |
29 | Installed with
30 | `pip