Skip to content

Commit 52df9de

Browse files
committed
feat: adds in doc response tooling
1 parent 2949372 commit 52df9de

39 files changed

+1702
-1
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ Thousands of code examples for using the EasyPost API across 7+ programming lang
66

77
## Folder Structure
88

9+
- `community` code snippets contributed from the community. These may include custom workflows, how to integrate EasyPost with other software, etc. These are **unofficial** and **not supported or maintained by EasyPost**.
910
- `official` official code snippets that populate on the EasyPost website
1011
- `docs` code snippets that populate on our API docs page. Each language will have its own subdirectory
1112
- `fixtures` test data used as fixtures in our client library test suites
1213
- `guides` code snippets that populate on our guides page. Each language will have its own subdirectory
1314
- `responses` responses for our example snippets found in the `docs` directory that will give you a good idea of what to expect back from the EasyPost API
14-
- `community` code snippets contributed from the community. These may include custom workflows, how to integrate EasyPost with other software, etc. These are **unofficial** and **not supported or maintained by EasyPost**.
15+
- `style_guides` contain the style guides and configs for tooling we use in each public language we support
16+
- `tools` contain utilities to help facilitate the code in this repo such as the response snippet generation suite
1517

1618
> NOTE: filenames may not match language convention - this is intentional. Our documentation website uses these example snippets and expects folder/filenames to follow a certain templated naming convention.
1719

tools/docs/responses/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
cassettes
2+
responses

tools/docs/responses/Makefile

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
PYTHON_BINARY := /opt/python3.10/bin/python3.10
2+
VIRTUAL_ENV := venv
3+
VIRTUAL_BIN := $(VIRTUAL_ENV)/bin
4+
PROJECT_NAME := easypost_responses
5+
TEST_DIR := tests
6+
TARGERT := tests
7+
8+
## help - Display help about make targets for this Makefile
9+
help:
10+
@cat Makefile | grep '^## ' --color=never | cut -c4- | sed -e "`printf 's/ - /\t- /;'`" | column -s "`printf '\t'`" -t
11+
12+
## build - Builds the project in preparation for release
13+
build:
14+
$(VIRTUAL_BIN)/python setup.py sdist bdist_wheel
15+
16+
## clean - Remove the virtual environment and clear out .pyc files
17+
clean:
18+
rm -rf $(VIRTUAL_ENV) dist/ build/ *.egg-info/ .pytest_cache .mypy_cache
19+
find . -name '*.pyc' -delete
20+
21+
## black - Runs the Black Python formatter against the project
22+
black:
23+
$(VIRTUAL_BIN)/black $(PROJECT_NAME)/ $(TEST_DIR)/
24+
25+
## black-check - Checks if the project is formatted correctly against the Black rules
26+
black-check:
27+
$(VIRTUAL_BIN)/black $(PROJECT_NAME)/ $(TEST_DIR)/ --check
28+
29+
## format - Runs all formatting tools against the project
30+
format: black isort
31+
32+
## format-check - Checks if the project is formatted correctly against all formatting rules
33+
format-check: black-check isort-check lint mypy
34+
35+
## install - Install the project locally
36+
install:
37+
$(PYTHON_BINARY) -m venv $(VIRTUAL_ENV)
38+
$(VIRTUAL_BIN)/pip install -e ."[dev]"
39+
git submodule init
40+
git submodule update
41+
42+
## isort - Sorts imports throughout the project
43+
isort:
44+
$(VIRTUAL_BIN)/isort $(PROJECT_NAME)/ $(TEST_DIR)/
45+
46+
## isort-check - Checks that imports throughout the project are sorted correctly
47+
isort-check:
48+
$(VIRTUAL_BIN)/isort $(PROJECT_NAME)/ $(TEST_DIR)/ --check-only
49+
50+
## lint - Lint the project
51+
lint:
52+
$(VIRTUAL_BIN)/flake8 $(PROJECT_NAME)/ $(TEST_DIR)/
53+
54+
## test - Test the project
55+
test:
56+
$(VIRTUAL_BIN)/pytest $(TARGET)
57+
58+
## update-examples-submodule Updates the examples submodule
59+
update-examples-submodule:
60+
git submodule update --remote ../../docs/examples
61+
62+
.PHONY: help build clean black black-check format format-check install isort isort-check lint test

tools/docs/responses/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Response Generation Tooling
2+
3+
This tool generates the response snippets that accompany the example snippets on the EasyPost Docs page.
4+
5+
The tool is setup as a "test suite" that will record the requests and responses via `pyvcr` and load the yaml to extract the response content to save as a standalone file. Whenever new responses are needed, simply re-run the tool and each response will be regenerated.
6+
7+
> NOTE: The test names are VERY important and must match up with the expected plural object and action name so that when generated, we can drop them into the examples without manual intervention.
8+
9+
## Usage
10+
11+
> NOTE: Currently, you'll need to run the command twice - it will fail the first time through. The first time will record the cassette, the second time it will save the standalone files. This is necessary because `vcrpy` doesn't save the file until the test function closes, though we call the standalone function inside the test function to capture the test name.
12+
13+
```bash
14+
# Run the following to save recordings to standalone files
15+
make test
16+
17+
# Run the following to overwrite previous interactions
18+
OVERWRITE=true make test
19+
```

tools/docs/responses/builder/__init__.py

Whitespace-only changes.
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import json
2+
import os
3+
from typing import (
4+
Any,
5+
Dict,
6+
Optional,
7+
Tuple,
8+
)
9+
10+
import yaml
11+
12+
13+
ALL_RESOURCES = {
14+
"addresses",
15+
"api-keys",
16+
"batches",
17+
"billing",
18+
"brand",
19+
"carrier-accounts",
20+
"carrier-types",
21+
"child-users",
22+
"customs-infos",
23+
"customs-items",
24+
"endshipper",
25+
"events",
26+
"insurance",
27+
"options",
28+
"orders",
29+
"parcels",
30+
"pickups",
31+
"rates",
32+
"referral-customers",
33+
"refunds",
34+
"reports",
35+
"returns",
36+
"scan-form",
37+
"shipments",
38+
"shipping-insurance",
39+
"smartrate",
40+
"tax-identifiers",
41+
"trackers",
42+
"users",
43+
"webhooks",
44+
}
45+
46+
47+
def build_response_snippet(
48+
interaction_index: Optional[int] = 0, objects_to_persist: Optional[int] = None
49+
):
50+
"""Builds the response snippet from a recorded VCR interaction."""
51+
create_dir("responses")
52+
53+
test_name = os.environ.get("PYTEST_CURRENT_TEST").split(":")[-1].split(" ")[0]
54+
cassette_filename = f"{test_name}.yaml"
55+
56+
cassette_content = extract_response_from_cassette(
57+
cassette_filename, interaction_index
58+
)
59+
60+
response_snippet_folder, bare_snippet_name = save_response_snippet(
61+
cassette_filename, cassette_content, objects_to_persist
62+
)
63+
64+
# Assert the standalone snippet actually got saved, fail if not
65+
assert os.path.exists(
66+
os.path.join("responses", response_snippet_folder, bare_snippet_name)
67+
), f"{bare_snippet_name} standalone snippet file missing!"
68+
69+
70+
def create_dir(dir_name: str):
71+
"""Creates a directory if it does not exist yet."""
72+
if not os.path.exists(dir_name):
73+
os.mkdir(dir_name)
74+
75+
76+
def extract_response_from_cassette(
77+
cassette_filename: str, interaction_index: Optional[int] = 0
78+
) -> Any:
79+
"""Opens a single cassette file and extracts the response content."""
80+
with open(os.path.join("tests", "cassettes", cassette_filename), "r") as cassette:
81+
try:
82+
cassette_data = yaml.safe_load(cassette)
83+
for key, _ in cassette_data.items():
84+
if key == "interactions":
85+
response_content = cassette_data[key][interaction_index][
86+
"response"
87+
]["body"]["string"]
88+
response = response_content if response_content else "{}"
89+
except yaml.YAMLError:
90+
raise
91+
92+
return response
93+
94+
95+
def _setup_saving_response_snippet(response_snippet_filename: str):
96+
"""Reusable helper to setup the logic to save a standalone response snippet."""
97+
98+
bare_snippet_name = response_snippet_filename.replace("test_", "").replace(
99+
".yaml", ".json"
100+
)
101+
split_resource_name = bare_snippet_name.split("_")
102+
first_resource_name = split_resource_name[0]
103+
second_resource_name = split_resource_name[1]
104+
first_and_second_resource_name = f"{first_resource_name}-{second_resource_name}"
105+
resource_name = (
106+
first_resource_name
107+
if first_and_second_resource_name not in ALL_RESOURCES
108+
else first_and_second_resource_name
109+
)
110+
111+
# Setup the names like the website wants it
112+
response_snippet_folder = resource_name.replace("_", "-")
113+
bare_snippet_name = bare_snippet_name.replace("_", "-")
114+
115+
create_dir(os.path.join("responses", response_snippet_folder))
116+
117+
return response_snippet_folder, bare_snippet_name
118+
119+
120+
def save_response_snippet(
121+
response_snippet_filename: str,
122+
response_snippet_content: Any,
123+
objects_to_persist: Optional[int] = None,
124+
) -> Tuple[str, str]:
125+
"""Saves the response content of a cassette to a standalone snippet file."""
126+
response_snippet_folder, bare_snippet_name = _setup_saving_response_snippet(
127+
response_snippet_filename
128+
)
129+
130+
with open(
131+
os.path.join("responses", response_snippet_folder, bare_snippet_name), "w"
132+
) as response_snippet_file:
133+
if objects_to_persist:
134+
json.dump(
135+
json.loads(response_snippet_content)[:objects_to_persist],
136+
response_snippet_file,
137+
indent=2,
138+
)
139+
else:
140+
json.dump(
141+
json.loads(response_snippet_content), response_snippet_file, indent=2
142+
)
143+
144+
response_snippet_file.write("\n")
145+
146+
return response_snippet_folder, bare_snippet_name
147+
148+
149+
def save_raw_json(response_snippet_filename: str, response_dict: Dict[str, Any]):
150+
"""Saves a raw response dictionary to a standalone snippet file (used for hard-coded responses
151+
that cannot easily be plugged into a test suite (eg: Billing functions).
152+
"""
153+
response_snippet_folder, bare_snippet_name = _setup_saving_response_snippet(
154+
response_snippet_filename
155+
)
156+
157+
bare_snippet_name = f"{bare_snippet_name}.json"
158+
159+
with open(
160+
os.path.join("responses", response_snippet_folder, bare_snippet_name), "w"
161+
) as response_snippet_file:
162+
json.dump(response_dict, response_snippet_file, indent=2)
163+
164+
response_snippet_file.write("\n")

tools/docs/responses/setup.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from setuptools import (
2+
find_packages,
3+
setup,
4+
)
5+
6+
7+
REQUIREMENTS = [
8+
"easypost==7.*",
9+
"python-dotenv",
10+
]
11+
12+
DEV_REQUIREMENTS = [
13+
"black==22.*",
14+
"flake8==5.*",
15+
"isort==5.*",
16+
"pytest-vcr==1.*",
17+
"pytest==7.*",
18+
"vcrpy==4.*",
19+
]
20+
21+
with open("README.md", encoding="utf-8") as f:
22+
long_description = f.read()
23+
24+
setup(
25+
name="easypost_responses",
26+
version="0.1.0",
27+
description="",
28+
author="EasyPost",
29+
author_email="[email protected]",
30+
url="https://easypost.com/",
31+
packages=find_packages(
32+
exclude=[
33+
"examples",
34+
"tests",
35+
]
36+
),
37+
install_requires=REQUIREMENTS,
38+
extras_require={
39+
"dev": DEV_REQUIREMENTS,
40+
},
41+
test_suite="test",
42+
long_description=long_description,
43+
long_description_content_type="text/markdown",
44+
python_requires=">=3.6, <4",
45+
)

tools/docs/responses/tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)