Skip to content

Commit 6fe9245

Browse files
committed
Merge branch 'main' into issues/355-query-in-get-request
2 parents 4b5aa8a + 00e4c98 commit 6fe9245

18 files changed

+438
-114
lines changed

.github/workflows/continuous-integration.yml

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,26 +20,41 @@ jobs:
2020
- "3.11"
2121
os:
2222
- ubuntu-latest
23-
# - windows-latest
23+
- windows-latest
24+
- macos-latest
2425
steps:
2526
- uses: actions/checkout@v3
2627

2728
- name: Set up Python ${{ matrix.python-version }}
2829
uses: actions/setup-python@v4
2930
with:
3031
python-version: ${{ matrix.python-version }}
31-
cache: 'pip'
32-
cache-dependency-path: 'setup.py'
32+
cache: "pip"
33+
cache-dependency-path: |
34+
setup.py
35+
requirements-dev.txt
3336
34-
- name: Execute linters and test suites
35-
run: ./scripts/cibuild
37+
- name: Install package
38+
run: pip install .
39+
40+
- name: Install dev requirements
41+
run: pip install -r requirements-dev.txt
42+
43+
- name: Run pre-commit
44+
run: pre-commit run --all-files
45+
46+
- name: Run pytest
47+
run: pytest -Werror -s --block-network --cov pystac_client --cov-report term-missing
48+
49+
- name: Run coverage
50+
run: coverage xml
3651

3752
- name: Upload All coverage to Codecov
3853
uses: codecov/codecov-action@v3
3954
with:
4055
token: ${{ secrets.CODECOV_TOKEN }}
4156
file: ./coverage.xml
42-
fail_ci_if_error: false
57+
fail_ci_if_error: false
4358

4459
min-versions:
4560
name: min-versions
@@ -49,8 +64,8 @@ jobs:
4964
- uses: actions/setup-python@v4
5065
with:
5166
python-version: 3.9
52-
cache: 'pip'
53-
cache-dependency-path: 'requirements-min.txt'
67+
cache: "pip"
68+
cache-dependency-path: "requirements-min.txt"
5469
- name: Install minimum requirements
5570
run: pip install -r requirements-min.txt
5671
- name: Install
@@ -68,8 +83,8 @@ jobs:
6883
- uses: actions/setup-python@v4
6984
with:
7085
python-version: 3.9
71-
cache: 'pip'
72-
cache-dependency-path: 'requirements-docs.txt'
86+
cache: "pip"
87+
cache-dependency-path: "requirements-docs.txt"
7388
- name: Install pandoc
7489
run: sudo apt-get update && sudo apt-get -y install pandoc
7590
- name: Install docs requirements
@@ -87,8 +102,8 @@ jobs:
87102
- uses: actions/setup-python@v4
88103
with:
89104
python-version: 3.9
90-
cache: 'pip'
91-
cache-dependency-path: 'setup.py'
105+
cache: "pip"
106+
cache-dependency-path: "setup.py"
92107
- name: Install
93108
run: pip install .
94109
- name: Install any pre-releases of pystac
@@ -97,3 +112,18 @@ jobs:
97112
run: pip install -r requirements-dev.txt
98113
- name: Test
99114
run: ./scripts/test
115+
116+
dev-and-docs-requirements:
117+
name: dev and docs requirements check
118+
runs-on: ubuntu-latest
119+
steps:
120+
- uses: actions/checkout@v3
121+
- uses: actions/setup-python@v4
122+
with:
123+
python-version: 3.9
124+
cache: "pip"
125+
cache-dependency-path: "setup.py"
126+
- name: Install
127+
run: pip install .
128+
- name: Install dev and docs requirements
129+
run: pip install -r requirements-dev.txt -r requirements-docs.txt

.pre-commit-config.yaml

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,34 +3,38 @@
33

44
repos:
55
- repo: https://github.com/psf/black
6-
rev: 22.3.0
6+
rev: 22.12.0
77
hooks:
88
- id: black
99
- repo: https://github.com/codespell-project/codespell
10-
rev: v2.1.0
10+
rev: v2.2.2
1111
hooks:
1212
- id: codespell
1313
args: [--ignore-words=.codespellignore]
1414
types_or: [jupyter, markdown, python, shell]
1515
- repo: https://github.com/PyCQA/doc8
16-
rev: 0.11.1
16+
rev: v1.1.1
1717
hooks:
1818
- id: doc8
19+
args: [--ignore=D004]
20+
additional_dependencies:
21+
- importlib_metadata < 5; python_version == "3.7"
1922
- repo: https://github.com/PyCQA/flake8
20-
rev: 4.0.1
23+
rev: 6.0.0
2124
hooks:
2225
- id: flake8
2326
- repo: https://github.com/pycqa/isort
24-
rev: 5.10.1
27+
rev: 5.11.4
2528
hooks:
2629
- id: isort
2730
name: isort (python)
2831
- repo: https://github.com/pre-commit/mirrors-mypy
29-
rev: v0.960
32+
rev: v0.991
3033
hooks:
3134
- id: mypy
3235
files: ".*\\.py$"
3336
additional_dependencies:
3437
- pystac
38+
- pytest-vcr
3539
- types-requests
3640
- types-python-dateutil

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1010
### Added
1111

1212
- Python 3.11 support [#347](https://github.com/stac-utils/pystac-client/pull/347)
13+
- `request_modifier` to `StacApiIO` to allow for additional authentication mechanisms (e.g. AWS SigV4) [#371](https://github.com/stac-utils/pystac-client/issues/371)
14+
- *Authentication* tutorial, demonstrating how to use to the provided hooks to use both basic and AWS SigV4 authentication [#371](https://github.com/stac-utils/pystac-client/issues/371)
15+
- CI checks for Windows and MacOS [#378](https://github.com/stac-utils/pystac-client/pull/378)
1316

1417
### Changed
1518

1619
### Fixed
1720

1821
- Some mishandled cases for datetime intervals [#363](https://github.com/stac-utils/pystac-client/pull/363)
1922
- `query` parameter in GET requests [#362](https://github.com/stac-utils/pystac-client/pull/362)
23+
- Collection requests when the Client's url ends in a '/' [#373](https://github.com/stac-utils/pystac-client/pull/373), [#405](https://github.com/stac-utils/pystac-client/pull/405)
24+
- Parse datetimes more strictly [#364](https://github.com/stac-utils/pystac-client/pull/364)
2025

2126
### Removed
2227

docs/tutorials.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,13 @@ Calculating Coverage Percentage of the AOI by an Item
4747
This tutorial demonstrates the use of pystac-client to calculate the
4848
percentage an Item's geometry that intesects with the area of interest
4949
(AOI) specified in the search by the `intersects` parameter.
50+
51+
Authentication
52+
--------------
53+
54+
- :tutorial:`GitHub version <authentication.md>`
55+
- :ref:`Docs version </tutorials/authentication.md>`
56+
57+
This tutorial demontrates different ways the pystac-client can be
58+
used to access a private stac api, when protected with various
59+
authentication mechanisms.

docs/tutorials/authentication.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Authentication
2+
3+
While not integrated into this library directly, pystac-client provides a series of hooks that support a wide variety of authentication mechanisms. These can be used when interacting with stac API implementations behind various authorization walls.
4+
5+
## Basic auth
6+
7+
Pystac-client supports HTTP basic authentication by simply exposing the ability to define headers to be used when sending requests. Simply encode the token and provide the header.
8+
9+
```python
10+
import base64
11+
import pystac_client
12+
13+
# encode credentials
14+
user_name = "yellowbeard"
15+
password = "yaarg"
16+
userpass = f"{user_name}:{password}"
17+
b64_userpass = base64.b64encode(userpass.encode()).decode()
18+
19+
# create the client
20+
client = pystac_client.Client.open(
21+
url="https://planetarycomputer.microsoft.com/api/stac/v1",
22+
headers={
23+
'Authorization': f"Basic {b64_userpass}"
24+
}
25+
)
26+
```
27+
28+
## Token auth
29+
30+
Providing a authentication token can be accomplished using the same mechanism as described above for [basic auth](#basic-auth). Simply provide the token in the `Authorization` header to the client in the same manner.
31+
32+
## AWS SigV4
33+
34+
Accessing a stac api protected by AWS IAM often requires signing the request using [AWS SigV4](https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html). Unlike basic and token authentication, the entire request is part of the signing process. Thus the `Authorization` header cannot be added when the client is created, rather it must be generated and added after the request is fully formed.
35+
36+
Pystac-client provides a lower-level hook, the `request_modifier` parameter, which can mutate the request, adding the necessary header after the request has been generated but before it is sent.
37+
38+
The code cell below demonstrates this, using the `boto3` module.
39+
40+
```python
41+
import boto3
42+
import botocore.auth
43+
import botocore.awsrequest
44+
import pystac_client
45+
import requests
46+
47+
# Details regarding the private stac api
48+
region = "us-east-1"
49+
service_name = "execute-api"
50+
endpoint_id = "xxxxxxxx"
51+
deployment_stage = "dev"
52+
stac_api_url = f"https://{endpoint_id}.{service_name}.{region}.amazonaws.com/{deployment_stage}"
53+
54+
# load AWS credentials
55+
credentials = boto3.Session(region_name=region).get_credentials()
56+
signer = botocore.auth.SigV4Auth(credentials, service_name, region)
57+
58+
def sign_request(request: requests.Request) -> requests.Request:
59+
"""Sign the request using AWS SigV4.
60+
61+
Args:
62+
request (requests.Request): The fully populated request to sign.
63+
64+
Returns:
65+
requests.Request: The provided request object, with auth header added.
66+
"""
67+
aws_request = botocore.awsrequest.AWSRequest(
68+
method=request.method,
69+
url=request.url,
70+
params=request.params,
71+
data=request.data,
72+
headers=request.headers
73+
)
74+
signer.add_auth(aws_request)
75+
request.headers = aws_request.headers
76+
return request
77+
78+
# create the client
79+
client = pystac_client.Client.open(
80+
url=stac_api_url,
81+
request_modifier=sign_request
82+
)
83+
```

pystac_client/client.py

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212
)
1313

1414
import pystac
15+
import pystac.utils
1516
import pystac.validation
1617
from pystac import CatalogType, Collection
18+
from requests import Request
1719

1820
from pystac_client._utils import Modifiable, call_modifier
1921
from pystac_client.collection_client import CollectionClient
@@ -93,6 +95,8 @@ def open(
9395
parameters: Optional[Dict[str, Any]] = None,
9496
ignore_conformance: bool = False,
9597
modifier: Optional[Callable[[Modifiable], None]] = None,
98+
request_modifier: Optional[Callable[[Request], Union[Request, None]]] = None,
99+
stac_io: Optional[StacApiIO] = None,
96100
) -> "Client":
97101
"""Opens a STAC Catalog or API
98102
This function will read the root catalog of a STAC Catalog or API
@@ -128,12 +132,31 @@ def open(
128132
After getting a child collection with, e.g.
129133
:meth:`Client.get_collection`, the child items of that collection
130134
will still be signed with ``modifier``.
135+
request_modifier: A callable that either modifies a `Request` instance or
136+
returns a new one. This can be useful for injecting Authentication
137+
headers and/or signing fully-formed requests (e.g. signing requests
138+
using AWS SigV4).
139+
140+
The callable should expect a single argument, which will be an instance
141+
of :class:`requests.Request`.
142+
143+
If the callable returns a `requests.Request`, that will be used.
144+
Alternately, the callable may simply modify the provided request object
145+
and return `None`.
146+
stac_io: A `StacApiIO` object to use for I/O requests. Generally, leave
147+
this to the default. However in cases where customized I/O processing
148+
is required, a custom instance can be provided here.
131149
132150
Return:
133151
catalog : A :class:`Client` instance for this Catalog/API
134152
"""
135153
client: Client = cls.from_file(
136-
url, headers=headers, parameters=parameters, modifier=modifier
154+
url,
155+
headers=headers,
156+
parameters=parameters,
157+
modifier=modifier,
158+
request_modifier=request_modifier,
159+
stac_io=stac_io,
137160
)
138161
search_link = client.get_search_link()
139162
# if there is a search link, but no conformsTo advertised, ignore
@@ -161,14 +184,19 @@ def from_file( # type: ignore
161184
headers: Optional[Dict[str, str]] = None,
162185
parameters: Optional[Dict[str, Any]] = None,
163186
modifier: Optional[Callable[[Modifiable], None]] = None,
187+
request_modifier: Optional[Callable[[Request], Union[Request, None]]] = None,
164188
) -> "Client":
165189
"""Open a STAC Catalog/API
166190
167191
Returns:
168192
Client: A Client (PySTAC Catalog) of the root Catalog for this Catalog/API
169193
"""
170194
if stac_io is None:
171-
stac_io = StacApiIO(headers=headers, parameters=parameters)
195+
stac_io = StacApiIO(
196+
headers=headers,
197+
parameters=parameters,
198+
request_modifier=request_modifier,
199+
)
172200

173201
client: Client = super().from_file(href, stac_io) # type: ignore
174202

@@ -226,7 +254,7 @@ def get_collection(self, collection_id: str) -> Optional[Collection]:
226254
CollectionClient: A STAC Collection
227255
"""
228256
if self._supports_collections() and self._stac_io:
229-
url = f"{self.get_self_href()}/collections/{collection_id}"
257+
url = self._get_collections_href(collection_id)
230258
collection = CollectionClient.from_dict(
231259
self._stac_io.read_json(url),
232260
root=self,
@@ -253,9 +281,9 @@ def get_collections(self) -> Iterator[Collection]:
253281
"""
254282
collection: Union[Collection, CollectionClient]
255283

256-
if self._supports_collections() and self.get_self_href() is not None:
257-
url = f"{self.get_self_href()}/collections"
258-
for page in self._stac_io.get_pages(url): # type: ignore
284+
if self._supports_collections() and self._stac_io:
285+
url = self._get_collections_href()
286+
for page in self._stac_io.get_pages(url):
259287
if "collections" not in page:
260288
raise APIError("Invalid response from /collections")
261289
for col in page["collections"]:
@@ -476,3 +504,36 @@ def get_search_link(self) -> Optional[pystac.Link]:
476504
),
477505
None,
478506
)
507+
508+
def _get_collections_href(self, collection_id: Optional[str] = None) -> str:
509+
self_href = self.get_self_href()
510+
if self_href is None:
511+
data_link = self.get_single_link("data")
512+
if data_link is None:
513+
raise ValueError(
514+
"cannot build a collections href without a self href or a data link"
515+
)
516+
else:
517+
collections_href = data_link.href
518+
else:
519+
collections_href = f"{self_href.rstrip('/')}/collections"
520+
521+
if not pystac.utils.is_absolute_href(collections_href):
522+
collections_href = self._make_absolute_href(collections_href)
523+
524+
if collection_id is None:
525+
return collections_href
526+
else:
527+
return f"{collections_href.rstrip('/')}/{collection_id}"
528+
529+
def _make_absolute_href(self, href: str) -> str:
530+
self_link = self.get_single_link("self")
531+
if self_link is None:
532+
raise ValueError("cannot build an absolute href without a self link")
533+
elif not pystac.utils.is_absolute_href(self_link.href):
534+
raise ValueError(
535+
"cannot build an absolute href from "
536+
f"a relative self link: {self_link.href}"
537+
)
538+
else:
539+
return pystac.utils.make_absolute_href(href, self_link.href)

0 commit comments

Comments
 (0)