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.
- airbyte_source_microsoft_sharepoint-0.2.0.dist-info/METADATA +113 -0
- airbyte_source_microsoft_sharepoint-0.2.0.dist-info/RECORD +10 -0
- airbyte_source_microsoft_sharepoint-0.2.0.dist-info/WHEEL +4 -0
- airbyte_source_microsoft_sharepoint-0.2.0.dist-info/entry_points.txt +3 -0
- source_microsoft_sharepoint/__init__.py +8 -0
- source_microsoft_sharepoint/run.py +23 -0
- source_microsoft_sharepoint/source.py +65 -0
- source_microsoft_sharepoint/spec.py +125 -0
- source_microsoft_sharepoint/stream_reader.py +278 -0
- source_microsoft_sharepoint/utils.py +85 -0
|
@@ -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,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)
|