airbyte-source-microsoft-sharepoint 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.1
2
+ Name: airbyte-source-microsoft-sharepoint
3
+ Version: 0.2.0
4
+ Summary: Source implementation for Microsoft SharePoint.
5
+ Home-page: https://airbyte.com
6
+ License: MIT
7
+ Author: Airbyte
8
+ Author-email: contact@airbyte.io
9
+ Requires-Python: >=3.9,<3.12
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Requires-Dist: Office365-REST-Python-Client (==2.5.5)
16
+ Requires-Dist: airbyte-cdk[file-based] (==0.61.0)
17
+ Requires-Dist: msal (==1.25.0)
18
+ Requires-Dist: smart-open (==6.4.0)
19
+ Project-URL: Documentation, https://docs.airbyte.com/integrations/sources/microsoft-sharepoint
20
+ Project-URL: Repository, https://github.com/airbytehq/airbyte
21
+ Description-Content-Type: text/markdown
22
+
23
+ # Microsoft SharePoint source connector
24
+
25
+
26
+ This is the repository for the Microsoft SharePoint source connector, written in Python.
27
+ For information about how to use this connector within Airbyte, see [the documentation](https://docs.airbyte.com/integrations/sources/microsoft-sharepoint).
28
+
29
+ ## Local development
30
+
31
+ ### Prerequisites
32
+ * Python (~=3.9)
33
+ * Poetry (~=1.7) - installation instructions [here](https://python-poetry.org/docs/#installation)
34
+
35
+
36
+ ### Installing the connector
37
+ From this connector directory, run:
38
+ ```bash
39
+ poetry install --with dev
40
+ ```
41
+
42
+
43
+ ### Create credentials
44
+ **If you are a community contributor**, follow the instructions in the [documentation](https://docs.airbyte.com/integrations/sources/microsoft-sharepoint)
45
+ to generate the necessary credentials. Then create a file `secrets/config.json` conforming to the `source_microsoft_sharepoint/spec.yaml` file.
46
+ Note that any directory named `secrets` is gitignored across the entire Airbyte repo, so there is no danger of accidentally checking in sensitive information.
47
+ See `sample_files/sample_config.json` for a sample config file.
48
+
49
+
50
+ ### Locally running the connector
51
+ ```
52
+ poetry run source-microsoft-sharepoint spec
53
+ poetry run source-microsoft-sharepoint check --config secrets/config.json
54
+ poetry run source-microsoft-sharepoint discover --config secrets/config.json
55
+ poetry run source-microsoft-sharepoint read --config secrets/config.json --catalog sample_files/configured_catalog.json
56
+ ```
57
+
58
+ ### Running unit tests
59
+ To run unit tests locally, from the connector directory run:
60
+ ```
61
+ poetry run pytest unit_tests
62
+ ```
63
+
64
+ ### Building the docker image
65
+ 1. Install [`airbyte-ci`](https://github.com/airbytehq/airbyte/blob/master/airbyte-ci/connectors/pipelines/README.md)
66
+ 2. Run the following command to build the docker image:
67
+ ```bash
68
+ airbyte-ci connectors --name=source-microsoft-sharepoint build
69
+ ```
70
+
71
+ An image will be available on your host with the tag `airbyte/source-microsoft-sharepoint:dev`.
72
+
73
+
74
+ ### Running as a docker container
75
+ Then run any of the connector commands as follows:
76
+ ```
77
+ docker run --rm airbyte/source-microsoft-sharepoint:dev spec
78
+ docker run --rm -v $(pwd)/secrets:/secrets airbyte/source-microsoft-sharepoint:dev check --config /secrets/config.json
79
+ docker run --rm -v $(pwd)/secrets:/secrets airbyte/source-microsoft-sharepoint:dev discover --config /secrets/config.json
80
+ docker run --rm -v $(pwd)/secrets:/secrets -v $(pwd)/integration_tests:/integration_tests airbyte/source-microsoft-sharepoint:dev read --config /secrets/config.json --catalog /integration_tests/configured_catalog.json
81
+ ```
82
+
83
+ ### Running our CI test suite
84
+ You can run our full test suite locally using [`airbyte-ci`](https://github.com/airbytehq/airbyte/blob/master/airbyte-ci/connectors/pipelines/README.md):
85
+ ```bash
86
+ airbyte-ci connectors --name=source-microsoft-sharepoint test
87
+ ```
88
+
89
+ ### Customizing acceptance Tests
90
+ Customize `acceptance-test-config.yml` file to configure acceptance tests. See [Connector Acceptance Tests](https://docs.airbyte.com/connector-development/testing-connectors/connector-acceptance-tests-reference) for more information.
91
+ If your connector requires to create or destroy resources for use during acceptance tests create fixtures for it and place them inside integration_tests/acceptance.py.
92
+
93
+ ### Dependency Management
94
+ All of your dependencies should be managed via Poetry.
95
+ To add a new dependency, run:
96
+ ```bash
97
+ poetry add <package-name>
98
+ ```
99
+
100
+ Please commit the changes to `pyproject.toml` and `poetry.lock` files.
101
+
102
+ ## Publishing a new version of the connector
103
+ You've checked out the repo, implemented a million dollar feature, and you're ready to share your changes with the world. Now what?
104
+ 1. Make sure your changes are passing our test suite: `airbyte-ci connectors --name=source-microsoft-sharepoint test`
105
+ 2. Bump the connector version (please follow [semantic versioning for connectors](https://docs.airbyte.com/contributing-to-airbyte/resources/pull-requests-handbook/#semantic-versioning-for-connectors)):
106
+ - bump the `dockerImageTag` value in in `metadata.yaml`
107
+ - bump the `version` value in `pyproject.toml`
108
+ 3. Make sure the `metadata.yaml` content is up to date.
109
+ 4. Make sure the connector documentation and its changelog is up to date (`docs/integrations/sources/microsoft-sharepoint.md`).
110
+ 5. Create a Pull Request: use [our PR naming conventions](https://docs.airbyte.com/contributing-to-airbyte/resources/pull-requests-handbook/#pull-request-title-convention).
111
+ 6. Pat yourself on the back for being an awesome contributor.
112
+ 7. Someone from Airbyte will take a look at your PR and iterate with you to merge it into master.
113
+ 8. Once your PR is merged, the new version of the connector will be automatically published to Docker Hub and our connector registry.
@@ -0,0 +1,10 @@
1
+ source_microsoft_sharepoint/__init__.py,sha256=a1qKp3Ui2-daZ3RA6LQ1BhcgMlB1reM-3io73TalbUw,150
2
+ source_microsoft_sharepoint/run.py,sha256=vyooBYcOlWf5ulMvNFmmHzTBS0sjxa5-rYljTqpO1m8,766
3
+ source_microsoft_sharepoint/source.py,sha256=c0a16gbjtA0N1XQ9pfcpBX7LaXjQFepPL6kfLsFf5GE,3211
4
+ source_microsoft_sharepoint/spec.py,sha256=B4Gpytf_CY0lT2awcgiRYIC6SSsQ4pVOcdxYnyHC1AE,5146
5
+ source_microsoft_sharepoint/stream_reader.py,sha256=CnGi2pYoyAoxqxFcszhfdWgvI5MOh3gRt0c9dX0GVHs,12207
6
+ source_microsoft_sharepoint/utils.py,sha256=Fmp29wIxjuZv6uAf74ejzd_ZasUdmZFQSdwIi-Cgk1I,3849
7
+ airbyte_source_microsoft_sharepoint-0.2.0.dist-info/METADATA,sha256=a0IpD7ALMkzCamx-beLqZxFsQ4SznZqAumU0nN3Ona4,5627
8
+ airbyte_source_microsoft_sharepoint-0.2.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
9
+ airbyte_source_microsoft_sharepoint-0.2.0.dist-info/entry_points.txt,sha256=qQVJCey5TyNCB-xZURXYwXMUbcy2tgmcJboz_VJEWvc,83
10
+ airbyte_source_microsoft_sharepoint-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.9.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ source-microsoft-sharepoint=source_microsoft_sharepoint.run:run
3
+
@@ -0,0 +1,8 @@
1
+ #
2
+ # Copyright (c) 2023 Airbyte, Inc., all rights reserved.
3
+ #
4
+
5
+
6
+ from .source import SourceMicrosoftSharePoint
7
+
8
+ __all__ = ["SourceMicrosoftSharePoint"]
@@ -0,0 +1,23 @@
1
+ #
2
+ # Copyright (c) 2023 Airbyte, Inc., all rights reserved.
3
+ #
4
+
5
+
6
+ import sys
7
+
8
+ from airbyte_cdk import AirbyteEntrypoint
9
+ from airbyte_cdk.entrypoint import launch
10
+ from source_microsoft_sharepoint.source import SourceMicrosoftSharePoint
11
+
12
+
13
+ def run():
14
+ args = sys.argv[1:]
15
+ catalog_path = AirbyteEntrypoint.extract_catalog(args)
16
+ config_path = AirbyteEntrypoint.extract_config(args)
17
+ state_path = AirbyteEntrypoint.extract_state(args)
18
+ source = SourceMicrosoftSharePoint(
19
+ SourceMicrosoftSharePoint.read_catalog(catalog_path) if catalog_path else None,
20
+ SourceMicrosoftSharePoint.read_config(config_path) if config_path else None,
21
+ SourceMicrosoftSharePoint.read_state(state_path) if state_path else None,
22
+ )
23
+ launch(source, args)
@@ -0,0 +1,65 @@
1
+ #
2
+ # Copyright (c) 2023 Airbyte, Inc., all rights reserved.
3
+ #
4
+
5
+
6
+ from typing import Any, Mapping, Optional
7
+
8
+ from airbyte_cdk.models import AdvancedAuth, ConfiguredAirbyteCatalog, ConnectorSpecification, OAuthConfigSpecification
9
+ from airbyte_cdk.sources.file_based.file_based_source import FileBasedSource
10
+ from airbyte_cdk.sources.file_based.stream.cursor.default_file_based_cursor import DefaultFileBasedCursor
11
+ from airbyte_cdk.sources.source import TState
12
+ from source_microsoft_sharepoint.spec import SourceMicrosoftSharePointSpec
13
+ from source_microsoft_sharepoint.stream_reader import SourceMicrosoftSharePointStreamReader
14
+
15
+
16
+ class SourceMicrosoftSharePoint(FileBasedSource):
17
+ def __init__(self, catalog: Optional[ConfiguredAirbyteCatalog], config: Optional[Mapping[str, Any]], state: Optional[TState]):
18
+ super().__init__(
19
+ stream_reader=SourceMicrosoftSharePointStreamReader(),
20
+ spec_class=SourceMicrosoftSharePointSpec,
21
+ catalog=catalog,
22
+ config=config,
23
+ state=state,
24
+ cursor_cls=DefaultFileBasedCursor,
25
+ )
26
+
27
+ def spec(self, *args: Any, **kwargs: Any) -> ConnectorSpecification:
28
+ """
29
+ Returns the specification describing what fields can be configured by a user when setting up a file-based source.
30
+ """
31
+
32
+ return ConnectorSpecification(
33
+ documentationUrl=self.spec_class.documentation_url(),
34
+ connectionSpecification=self.spec_class.schema(),
35
+ advanced_auth=AdvancedAuth(
36
+ auth_flow_type="oauth2.0",
37
+ predicate_key=["credentials", "auth_type"],
38
+ predicate_value="Client",
39
+ oauth_config_specification=OAuthConfigSpecification(
40
+ complete_oauth_output_specification={
41
+ "type": "object",
42
+ "additionalProperties": False,
43
+ "properties": {"refresh_token": {"type": "string", "path_in_connector_config": ["credentials", "refresh_token"]}},
44
+ },
45
+ complete_oauth_server_input_specification={
46
+ "type": "object",
47
+ "additionalProperties": False,
48
+ "properties": {"client_id": {"type": "string"}, "client_secret": {"type": "string"}},
49
+ },
50
+ complete_oauth_server_output_specification={
51
+ "type": "object",
52
+ "additionalProperties": False,
53
+ "properties": {
54
+ "client_id": {"type": "string", "path_in_connector_config": ["credentials", "client_id"]},
55
+ "client_secret": {"type": "string", "path_in_connector_config": ["credentials", "client_secret"]},
56
+ },
57
+ },
58
+ oauth_user_input_from_connector_config_specification={
59
+ "type": "object",
60
+ "additionalProperties": False,
61
+ "properties": {"tenant_id": {"type": "string", "path_in_connector_config": ["credentials", "tenant_id"]}},
62
+ },
63
+ ),
64
+ ),
65
+ )
@@ -0,0 +1,125 @@
1
+ #
2
+ # Copyright (c) 2023 Airbyte, Inc., all rights reserved.
3
+ #
4
+
5
+
6
+ from typing import Any, Dict, Literal, Optional, Union
7
+
8
+ import dpath.util
9
+ from airbyte_cdk.sources.file_based.config.abstract_file_based_spec import AbstractFileBasedSpec
10
+ from pydantic import BaseModel, Field
11
+
12
+
13
+ class OAuthCredentials(BaseModel):
14
+ """
15
+ OAuthCredentials class to hold authentication details for Microsoft OAuth authentication.
16
+ This class uses pydantic for data validation and settings management.
17
+ """
18
+
19
+ class Config:
20
+ title = "Authenticate via Microsoft (OAuth)"
21
+
22
+ # Fields for the OAuth authentication, including tenant_id, client_id, client_secret, and refresh_token
23
+ auth_type: Literal["Client"] = Field("Client", const=True)
24
+ tenant_id: str = Field(title="Tenant ID", description="Tenant ID of the Microsoft SharePoint user", airbyte_secret=True)
25
+ client_id: str = Field(
26
+ title="Client ID",
27
+ description="Client ID of your Microsoft developer application",
28
+ airbyte_secret=True,
29
+ )
30
+ client_secret: str = Field(
31
+ title="Client Secret",
32
+ description="Client Secret of your Microsoft developer application",
33
+ airbyte_secret=True,
34
+ )
35
+ refresh_token: str = Field(
36
+ title="Refresh Token",
37
+ description="Refresh Token of your Microsoft developer application",
38
+ airbyte_secret=True,
39
+ )
40
+
41
+
42
+ class ServiceCredentials(BaseModel):
43
+ """
44
+ ServiceCredentials class for service key authentication.
45
+ This class is structured similarly to OAuthCredentials but for a different authentication method.
46
+ """
47
+
48
+ class Config:
49
+ title = "Service Key Authentication"
50
+
51
+ # Fields for the Service authentication, similar to OAuthCredentials
52
+ auth_type: Literal["Service"] = Field("Service", const=True)
53
+ tenant_id: str = Field(title="Tenant ID", description="Tenant ID of the Microsoft SharePoint user", airbyte_secret=True)
54
+ user_principal_name: str = Field(
55
+ title="User Principal Name",
56
+ description="Special characters such as a period, comma, space, and the at sign (@) are converted to underscores (_). More details: https://learn.microsoft.com/en-us/sharepoint/list-onedrive-urls",
57
+ airbyte_secret=True,
58
+ )
59
+ client_id: str = Field(
60
+ title="Client ID",
61
+ description="Client ID of your Microsoft developer application",
62
+ airbyte_secret=True,
63
+ )
64
+ client_secret: str = Field(
65
+ title="Client Secret",
66
+ description="Client Secret of your Microsoft developer application",
67
+ airbyte_secret=True,
68
+ )
69
+
70
+
71
+ class SourceMicrosoftSharePointSpec(AbstractFileBasedSpec, BaseModel):
72
+ """
73
+ SourceMicrosoftSharePointSpec class for Microsoft SharePoint Source Specification.
74
+ This class combines the authentication details with additional configuration for the SharePoint API.
75
+ """
76
+
77
+ class Config:
78
+ title = "Microsoft SharePoint Source Spec"
79
+
80
+ # Union type for credentials, allowing for either OAuth or Service Key authentication
81
+ credentials: Union[OAuthCredentials, ServiceCredentials] = Field(
82
+ title="Authentication",
83
+ description="Credentials for connecting to the One Drive API",
84
+ discriminator="auth_type",
85
+ type="object",
86
+ order=0,
87
+ )
88
+
89
+ search_scope: str = Field(
90
+ title="Search Scope",
91
+ description="Specifies the location(s) to search for files. Valid options are 'ACCESSIBLE_DRIVES' for all SharePoint drives the user can access, 'SHARED_ITEMS' for shared items the user has access to, and 'ALL' to search both.",
92
+ default="ALL",
93
+ enum=["ACCESSIBLE_DRIVES", "SHARED_ITEMS", "ALL"],
94
+ order=3,
95
+ )
96
+
97
+ folder_path: str = Field(
98
+ title="Folder Path",
99
+ description="Path to a specific folder within the drives to search for files. Leave empty to search all folders of the drives. This does not apply to shared items.",
100
+ order=4,
101
+ default=".",
102
+ )
103
+
104
+ @classmethod
105
+ def documentation_url(cls) -> str:
106
+ """Provides the URL to the documentation for this specific source."""
107
+ return "https://docs.airbyte.com/integrations/sources/microsoft-sharepoint"
108
+
109
+ @classmethod
110
+ def schema(cls, *args: Any, **kwargs: Any) -> Dict[str, Any]:
111
+ """
112
+ Generates the schema mapping for configuration fields.
113
+ It also cleans up the schema by removing legacy settings and discriminators.
114
+ """
115
+ schema = super().schema(*args, **kwargs)
116
+
117
+ # Remove legacy settings related to streams
118
+ dpath.util.delete(schema, "properties/streams/items/properties/legacy_prefix")
119
+ dpath.util.delete(schema, "properties/streams/items/properties/format/oneOf/*/properties/inference_type")
120
+
121
+ # Hide API processing option until https://github.com/airbytehq/airbyte-platform-internal/issues/10354 is fixed
122
+ processing_options = dpath.util.get(schema, "properties/streams/items/properties/format/oneOf/4/properties/processing/oneOf")
123
+ dpath.util.set(schema, "properties/streams/items/properties/format/oneOf/4/properties/processing/oneOf", processing_options[:1])
124
+
125
+ return schema
@@ -0,0 +1,278 @@
1
+ #
2
+ # Copyright (c) 2023 Airbyte, Inc., all rights reserved.
3
+ #
4
+
5
+ import logging
6
+ from datetime import datetime
7
+ from functools import lru_cache
8
+ from io import IOBase
9
+ from typing import Iterable, List, Optional, Tuple
10
+
11
+ import requests
12
+ import smart_open
13
+ from airbyte_cdk.sources.file_based.file_based_stream_reader import AbstractFileBasedStreamReader, FileReadMode
14
+ from airbyte_cdk.sources.file_based.remote_file import RemoteFile
15
+ from airbyte_cdk.utils.traced_exception import AirbyteTracedException, FailureType
16
+ from msal import ConfidentialClientApplication
17
+ from office365.graph_client import GraphClient
18
+ from source_microsoft_sharepoint.spec import SourceMicrosoftSharePointSpec
19
+
20
+ from .utils import MicrosoftSharePointRemoteFile, execute_query_with_retry, filter_http_urls
21
+
22
+
23
+ class SourceMicrosoftSharePointClient:
24
+ """
25
+ Client to interact with Microsoft SharePoint.
26
+ """
27
+
28
+ def __init__(self, config: SourceMicrosoftSharePointSpec):
29
+ self.config = config
30
+ self._client = None
31
+ self._msal_app = ConfidentialClientApplication(
32
+ self.config.credentials.client_id,
33
+ authority=f"https://login.microsoftonline.com/{self.config.credentials.tenant_id}",
34
+ client_credential=self.config.credentials.client_secret,
35
+ )
36
+
37
+ @property
38
+ def client(self):
39
+ """Initializes and returns a GraphClient instance."""
40
+ if not self.config:
41
+ raise ValueError("Configuration is missing; cannot create the Office365 graph client.")
42
+ if not self._client:
43
+ self._client = GraphClient(self._get_access_token)
44
+ return self._client
45
+
46
+ def _get_access_token(self):
47
+ """Retrieves an access token for SharePoint access."""
48
+ scope = ["https://graph.microsoft.com/.default"]
49
+ refresh_token = self.config.credentials.refresh_token if hasattr(self.config.credentials, "refresh_token") else None
50
+
51
+ if refresh_token:
52
+ result = self._msal_app.acquire_token_by_refresh_token(refresh_token, scopes=scope)
53
+ else:
54
+ result = self._msal_app.acquire_token_for_client(scopes=scope)
55
+
56
+ if "access_token" not in result:
57
+ error_description = result.get("error_description", "No error description provided.")
58
+ message = f"Failed to acquire access token. Error: {result.get('error')}. Error description: {error_description}."
59
+ raise AirbyteTracedException(message=message, failure_type=FailureType.config_error)
60
+
61
+ return result
62
+
63
+
64
+ class SourceMicrosoftSharePointStreamReader(AbstractFileBasedStreamReader):
65
+ """
66
+ A stream reader for Microsoft SharePoint. Handles file enumeration and reading from SharePoint.
67
+ """
68
+
69
+ ROOT_PATH = [".", "/"]
70
+
71
+ def __init__(self):
72
+ super().__init__()
73
+ self._auth_client = None
74
+ self._one_drive_client = None
75
+
76
+ @property
77
+ def config(self) -> SourceMicrosoftSharePointSpec:
78
+ return self._config
79
+
80
+ @property
81
+ def auth_client(self):
82
+ # Lazy initialization of the auth_client
83
+ if self._auth_client is None:
84
+ self._auth_client = SourceMicrosoftSharePointClient(self._config)
85
+ return self._auth_client
86
+
87
+ @property
88
+ def one_drive_client(self):
89
+ # Lazy initialization of the one_drive_client
90
+ if self._one_drive_client is None:
91
+ self._one_drive_client = self.auth_client.client
92
+ return self._one_drive_client
93
+
94
+ def get_access_token(self):
95
+ # Directly fetch a new access token from the auth_client each time it's called
96
+ return self.auth_client._get_access_token()["access_token"]
97
+
98
+ @config.setter
99
+ def config(self, value: SourceMicrosoftSharePointSpec):
100
+ """
101
+ The FileBasedSource reads and parses configuration from a file, then sets this configuration in its StreamReader. While it only
102
+ uses keys from its abstract configuration, concrete StreamReader implementations may need additional keys for third-party
103
+ authentication. Therefore, subclasses of AbstractFileBasedStreamReader should verify that the value in their config setter
104
+ matches the expected config type for their StreamReader.
105
+ """
106
+ assert isinstance(value, SourceMicrosoftSharePointSpec)
107
+ self._config = value
108
+
109
+ def _get_shared_drive_object(self, drive_id: str, object_id: str, path: str) -> List[Tuple[str, str, datetime]]:
110
+ """
111
+ Retrieves a list of all nested files under the specified object.
112
+
113
+ Args:
114
+ drive_id: The ID of the drive containing the object.
115
+ object_id: The ID of the object to start the search from.
116
+
117
+ Returns:
118
+ A list of tuples containing file information (name, download URL, and last modified datetime).
119
+
120
+ Raises:
121
+ RuntimeError: If an error occurs during the request.
122
+ """
123
+
124
+ access_token = self.get_access_token()
125
+ headers = {"Authorization": f"Bearer {access_token}"}
126
+ base_url = f"https://graph.microsoft.com/v1.0/drives/{drive_id}"
127
+
128
+ def get_files(url: str, path: str) -> List[Tuple[str, str, datetime]]:
129
+ response = requests.get(url, headers=headers)
130
+ if response.status_code != 200:
131
+ error_info = response.json().get("error", {}).get("message", "No additional error information provided.")
132
+ raise RuntimeError(f"Failed to retrieve files from URL '{url}'. HTTP status: {response.status_code}. Error: {error_info}")
133
+
134
+ data = response.json()
135
+ for child in data.get("value", []):
136
+ new_path = path + "/" + child["name"]
137
+ if child.get("file"): # Object is a file
138
+ last_modified = datetime.strptime(child["lastModifiedDateTime"], "%Y-%m-%dT%H:%M:%SZ")
139
+ yield (new_path, child["@microsoft.graph.downloadUrl"], last_modified)
140
+ else: # Object is a folder, retrieve children
141
+ child_url = f"{base_url}/items/{child['id']}/children" # Use item endpoint for nested objects
142
+ yield from get_files(child_url, new_path)
143
+ yield from []
144
+
145
+ # Initial request to item endpoint
146
+ item_url = f"{base_url}/items/{object_id}"
147
+ item_response = requests.get(item_url, headers=headers)
148
+ if item_response.status_code != 200:
149
+ error_info = item_response.json().get("error", {}).get("message", "No additional error information provided.")
150
+ raise RuntimeError(
151
+ f"Failed to retrieve the initial shared object with ID '{object_id}' from drive '{drive_id}'. "
152
+ f"HTTP status: {item_response.status_code}. Error: {error_info}"
153
+ )
154
+
155
+ # Check if the object is a file or a folder
156
+ item_data = item_response.json()
157
+ if item_data.get("file"): # Initial object is a file
158
+ new_path = path + "/" + item_data["name"]
159
+ last_modified = datetime.strptime(item_data["lastModifiedDateTime"], "%Y-%m-%dT%H:%M:%SZ")
160
+ yield (new_path, item_data["@microsoft.graph.downloadUrl"], last_modified)
161
+ else:
162
+ # Initial object is a folder, start file retrieval
163
+ yield from get_files(f"{item_url}/children", path)
164
+
165
+ def _list_directories_and_files(self, root_folder, path):
166
+ """Enumerates folders and files starting from a root folder."""
167
+ drive_items = execute_query_with_retry(root_folder.children.get())
168
+ for item in drive_items:
169
+ item_path = path + "/" + item.name if path else item.name
170
+ if item.is_file:
171
+ yield (item_path, item.properties["@microsoft.graph.downloadUrl"], item.properties["lastModifiedDateTime"])
172
+ else:
173
+ yield from self._list_directories_and_files(item, item_path)
174
+ yield from []
175
+
176
+ def _get_files_by_drive_name(self, drives, folder_path):
177
+ """Yields files from the specified drive."""
178
+ path_levels = [level for level in folder_path.split("/") if level]
179
+ folder_path = "/".join(path_levels)
180
+
181
+ for drive in drives:
182
+ is_sharepoint = drive.drive_type == "documentLibrary"
183
+ if is_sharepoint:
184
+ # Define base path for drive files to differentiate files between drives
185
+ if folder_path in self.ROOT_PATH:
186
+ folder = drive.root
187
+ folder_path_url = drive.web_url
188
+ else:
189
+ folder = execute_query_with_retry(drive.root.get_by_path(folder_path).get())
190
+ folder_path_url = drive.web_url + "/" + folder_path
191
+
192
+ yield from self._list_directories_and_files(folder, folder_path_url)
193
+
194
+ @property
195
+ @lru_cache(maxsize=None)
196
+ def drives(self):
197
+ """
198
+ Retrieves and caches SharePoint drives, including the user's drive based on authentication type.
199
+ """
200
+ drives = execute_query_with_retry(self.one_drive_client.drives.get())
201
+
202
+ if self.config.credentials.auth_type == "Client":
203
+ my_drive = execute_query_with_retry(self.one_drive_client.me.drive.get())
204
+ else:
205
+ my_drive = execute_query_with_retry(
206
+ self.one_drive_client.users.get_by_principal_name(self.config.credentials.user_principal_name).drive.get()
207
+ )
208
+
209
+ drives.add_child(my_drive)
210
+
211
+ return drives
212
+
213
+ def _get_shared_files_from_all_drives(self, parsed_drives):
214
+ drive_ids = [drive.id for drive in parsed_drives]
215
+
216
+ shared_drive_items = execute_query_with_retry(self.one_drive_client.me.drive.shared_with_me())
217
+ for drive_item in shared_drive_items:
218
+ parent_reference = drive_item.remote_item.parentReference
219
+
220
+ # check if drive is already parsed
221
+ if parent_reference and parent_reference["driveId"] not in drive_ids:
222
+ yield from self._get_shared_drive_object(parent_reference["driveId"], drive_item.id, drive_item.web_url)
223
+
224
+ def get_all_files(self):
225
+ if self.config.search_scope in ("ACCESSIBLE_DRIVES", "ALL"):
226
+ # Get files from accessible drives
227
+ yield from self._get_files_by_drive_name(self.drives, self.config.folder_path)
228
+
229
+ if self.config.search_scope in ("SHARED_ITEMS", "ALL"):
230
+ parsed_drives = [] if self.config.search_scope == "SHARED_ITEMS" else self.drives
231
+
232
+ # Get files from shared items
233
+ yield from self._get_shared_files_from_all_drives(parsed_drives)
234
+
235
+ def get_matching_files(self, globs: List[str], prefix: Optional[str], logger: logging.Logger) -> Iterable[RemoteFile]:
236
+ """
237
+ Retrieve all files matching the specified glob patterns in SharePoint.
238
+ """
239
+ files = self.get_all_files()
240
+
241
+ files_generator = filter_http_urls(
242
+ self.filter_files_by_globs_and_start_date(
243
+ [
244
+ MicrosoftSharePointRemoteFile(
245
+ uri=path,
246
+ download_url=download_url,
247
+ last_modified=last_modified,
248
+ )
249
+ for path, download_url, last_modified in files
250
+ ],
251
+ globs,
252
+ ),
253
+ logger,
254
+ )
255
+
256
+ items_processed = False
257
+ for file in files_generator:
258
+ items_processed = True
259
+ yield file
260
+
261
+ if not items_processed:
262
+ raise AirbyteTracedException(
263
+ message=f"Drive is empty or does not exist.",
264
+ failure_type=FailureType.config_error,
265
+ )
266
+
267
+ def open_file(self, file: RemoteFile, mode: FileReadMode, encoding: Optional[str], logger: logging.Logger) -> IOBase:
268
+ # choose correct compression mode because the url is random and doesn't end with filename extension
269
+ file_extension = file.uri.split(".")[-1]
270
+ if file_extension in ["gz", "bz2"]:
271
+ compression = "." + file_extension
272
+ else:
273
+ compression = "disable"
274
+
275
+ try:
276
+ return smart_open.open(file.download_url, mode=mode.value, compression=compression, encoding=encoding)
277
+ except Exception as e:
278
+ logger.exception(f"Error opening file {file.uri}: {e}")
@@ -0,0 +1,85 @@
1
+ # Copyright (c) 2023 Airbyte, Inc., all rights reserved.
2
+
3
+ import time
4
+ from datetime import datetime, timedelta
5
+ from enum import Enum
6
+ from http import HTTPStatus
7
+
8
+ from airbyte_cdk.models import FailureType
9
+ from airbyte_cdk.sources.file_based.remote_file import RemoteFile
10
+ from airbyte_cdk.utils.traced_exception import AirbyteTracedException
11
+
12
+
13
+ class SearchScope(Enum):
14
+ OWN_DRIVES = "OWN_DRIVES"
15
+ SHARED_ITEMS = "SHARED_ITEMS"
16
+ BOTH = "BOTH"
17
+
18
+
19
+ class MicrosoftSharePointRemoteFile(RemoteFile):
20
+ download_url: str
21
+
22
+
23
+ def filter_http_urls(files, logger):
24
+ for file in files:
25
+ if file.download_url.startswith("http") and not file.download_url.startswith("https"): # ignore-https-check
26
+ logger.error(f"Cannot open file {file.uri}. The URL returned by SharePoint is not secure.")
27
+ else:
28
+ yield file
29
+
30
+
31
+ def execute_query_with_retry(obj, max_retries=5, initial_retry_after=5, max_retry_after=300, max_total_wait_time=600):
32
+ """
33
+ Executes a query with retry logic on encountering specific HTTP errors.
34
+
35
+ This function attempts to execute `obj.execute_query()` method, applying exponential backoff
36
+ retry logic for HTTP status codes 429 (Too Many Requests) and 503 (Service Unavailable). It
37
+ respects the 'Retry-After' header from the response, if present.
38
+
39
+ Parameters:
40
+ obj (object): The object that has the `execute_query` method to be executed.
41
+ max_retries (int): Maximum number of retry attempts. Defaults to 5.
42
+ initial_retry_after (int): Initial waiting time (in seconds) before the first retry. Defaults to 5 seconds.
43
+ max_retry_after (int): Maximum waiting time (in seconds) between retries. Defaults to 300 seconds.
44
+ max_total_wait_time (int): Maximum total waiting time (in seconds) for all retries. Defaults to 600 seconds.
45
+
46
+ Raises:
47
+ AirbyteTracedException: If the maximum total wait time or the maximum number of retries is exceeded.
48
+
49
+ Returns:
50
+ The result of `obj.execute_query()` if successful within the retry constraints.
51
+ """
52
+ retries = 0
53
+ start_time = datetime.now()
54
+ retry_after = initial_retry_after
55
+
56
+ while retries < max_retries:
57
+ try:
58
+ return obj.execute_query()
59
+ except Exception as ex:
60
+ if hasattr(ex, "response") and ex.response.status_code in (HTTPStatus.TOO_MANY_REQUESTS, HTTPStatus.SERVICE_UNAVAILABLE):
61
+ current_time = datetime.now()
62
+ elapsed_time = (current_time - start_time).total_seconds()
63
+
64
+ retry_after_header = ex.response.headers.get("Retry-After", None)
65
+ if retry_after_header:
66
+ retry_after = int(retry_after_header)
67
+
68
+ if elapsed_time + retry_after > max_total_wait_time:
69
+ message = (
70
+ f"Maximum total wait time of {max_total_wait_time} seconds exceeded for execute_query. "
71
+ f"The latest response status code is {ex.response.status_code}."
72
+ )
73
+ if retry_after_header:
74
+ message += f" Retry-After header: {retry_after_header}"
75
+ raise AirbyteTracedException(message, message, failure_type=FailureType.system_error)
76
+
77
+ time.sleep(retry_after)
78
+ retries += 1
79
+ retry_after = min(retry_after * 2, max_retry_after) # Double the wait time for next retry, up to a max limit
80
+ else:
81
+ # Re-raise exceptions that are not related to rate limits or service availability
82
+ raise AirbyteTracedException.from_exception(ex, message="Caught unexpected exception")
83
+
84
+ message = f"Maximum number of retries of {max_retries} exceeded for execute_query."
85
+ raise AirbyteTracedException(message, message, failure_type=FailureType.system_error)