idi-sanmar-sdk 1.0.0__tar.gz
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.
- idi_sanmar_sdk-1.0.0/PKG-INFO +101 -0
- idi_sanmar_sdk-1.0.0/README.md +86 -0
- idi_sanmar_sdk-1.0.0/pyproject.toml +114 -0
- idi_sanmar_sdk-1.0.0/pyproject.toml.orig +135 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/__init__.py +49 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/_snapshots.py +118 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/_soap.py +287 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/base.py +156 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/client.py +73 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/common.py +213 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/exceptions.py +76 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/ftp/__init__.py +59 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/ftp/catalog.py +284 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/ftp/client.py +414 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/ftp/inventory.py +114 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/ftp/legacy.py +124 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/ftp/orders.py +253 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/ftp/pricing.py +96 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/ftp/readers.py +138 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/orders.py +74 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/__init__.py +107 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/_common.py +147 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/_wire.py +260 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/client.py +38 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/inventory.py +125 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/invoices.py +146 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/media_content.py +92 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/order_status.py +249 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/pricing.py +113 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/product_data.py +309 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/purchase_orders.py +183 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/shipment_notification.py +156 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/py.typed +0 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/sources.py +46 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/standard/__init__.py +40 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/standard/_common.py +70 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/standard/_wire.py +185 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/standard/inventory.py +100 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/standard/invoices.py +210 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/standard/packing_slips.py +119 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/standard/pricing.py +79 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/standard/products.py +192 -0
- idi_sanmar_sdk-1.0.0/src/sanmar_sdk/standard/purchase_orders.py +157 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: idi-sanmar-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A typed Python SDK for SanMar’s web services and SFTP files.
|
|
5
|
+
Author: Bradley Reynolds
|
|
6
|
+
Author-email: Bradley Reynolds <bradley.reynolds@impressdesigns.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Requires-Dist: paramiko>=4.0,<5
|
|
9
|
+
Requires-Dist: pydantic>=2.12
|
|
10
|
+
Requires-Dist: zeep>=4.3.3
|
|
11
|
+
Requires-Python: >=3.14
|
|
12
|
+
Project-URL: repository, https://github.com/impressdesigns/sanmar-sdk
|
|
13
|
+
Project-URL: documentation, https://impressdesigns.dev/sanmar-sdk/
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# idi-sanmar-sdk
|
|
17
|
+
|
|
18
|
+
A typed Python SDK for [SanMar](https://www.sanmar.com): its own web services, its
|
|
19
|
+
PromoStandards web services, and the data and order files on its SFTP server.
|
|
20
|
+
|
|
21
|
+
- **Strict in, typed out.** Orders and lookups are validated Pydantic models, so a bad
|
|
22
|
+
address or a comma in a PO number fails before anything is sent. Responses come back as
|
|
23
|
+
typed records, with prices as `Decimal`.
|
|
24
|
+
- **No SOAP or file layouts to learn.** Each call is shaped into SanMar's payload in one
|
|
25
|
+
place, including the PromoStandards fields SanMar requires and then ignores.
|
|
26
|
+
- **Streams.** SFTP files are parsed row by row as they download, zip archives included.
|
|
27
|
+
|
|
28
|
+
Documentation: https://impressdesigns.dev/sanmar-sdk/
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
uv add idi-sanmar-sdk
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Python 3.14 or newer. SanMar's web services listen on port 8080, and its SFTP server on
|
|
37
|
+
port 2200; some networks block both.
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from sanmar_sdk import SanMar, ShipMethod, ShipTo
|
|
43
|
+
from sanmar_sdk.promostandards import PromoStandardsOrder, PromoStandardsOrderLine
|
|
44
|
+
|
|
45
|
+
sanmar = SanMar(123456, "sanmar-username", "sanmar-password")
|
|
46
|
+
|
|
47
|
+
# Look a style up, and check stock for one of its parts.
|
|
48
|
+
product = sanmar.promostandards.product_data.get("K500")
|
|
49
|
+
[stock] = sanmar.promostandards.inventory.get("K500", part_ids=["208284"])
|
|
50
|
+
|
|
51
|
+
# Place an order.
|
|
52
|
+
order = PromoStandardsOrder(
|
|
53
|
+
po_number="85496",
|
|
54
|
+
ship_to=ShipTo(address1="1404 W MAIN ST", city="CARROLLTON", state="TX", zip_code="75006"),
|
|
55
|
+
ship_method=ShipMethod.UPS_GROUND,
|
|
56
|
+
lines=[PromoStandardsOrderLine(part_id="208284", quantity=12)],
|
|
57
|
+
)
|
|
58
|
+
transaction_id = sanmar.promostandards.purchase_orders.send(order)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Reading the catalog over SFTP:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from sanmar_sdk.ftp import SanMarFTP
|
|
65
|
+
|
|
66
|
+
with SanMarFTP(123456, "ftp-password", host_key="ssh-rsa AAAA...") as ftp, ftp.catalog() as products:
|
|
67
|
+
for product in products:
|
|
68
|
+
print(product.style, product.catalog_color, product.size, product.unique_key)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Colors in requests and orders are SanMar's **catalog** colors (`SANMAR_MAINFRAME_COLOR`),
|
|
72
|
+
not the display names in `COLOR_NAME`. The SDK calls them `catalog_color` everywhere; see
|
|
73
|
+
[Identifiers](https://impressdesigns.dev/sanmar-sdk/identifiers/).
|
|
74
|
+
|
|
75
|
+
## Sources
|
|
76
|
+
|
|
77
|
+
Written against these revisions of SanMar's integration guides:
|
|
78
|
+
|
|
79
|
+
| Guide | Version | Updated |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| [SanMar Web Services Integration Guide](https://www.sanmar.com/medias/sys_master/pdf/h7a/ha0/33815499964446/SanMar-Web-Services-Integration-Guide-24.6/SanMar-Web-Services-Integration-Guide-24.6.pdf) | 24.6 | August 2026 |
|
|
82
|
+
| [SanMar Purchase Order Integration Guide](https://www.sanmar.com/medias/sys_master/root/h4b/ha7/33815499767838/SanMar-Purchase-Order-Integration-Guide-24.5/SanMar-Purchase-Order-Integration-Guide-24.5.pdf) | 24.5 | August 2026 |
|
|
83
|
+
| [SanMar FTP Integration Guide](https://www.sanmar.com/medias/sys_master/root/h08/hae/33815499571230/SanMar-FTP-Integration-Guide-v23.6/SanMar-FTP-Integration-Guide-v23.6.pdf) | 23.6 | August 2026 |
|
|
84
|
+
|
|
85
|
+
Where the guides contradict themselves, the SDK follows SanMar's own WSDLs and file
|
|
86
|
+
headers, recorded in `tests/`. The
|
|
87
|
+
[Sources](https://impressdesigns.dev/sanmar-sdk/sources/) page lists each case.
|
|
88
|
+
|
|
89
|
+
## Development
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
uv sync --all-groups
|
|
93
|
+
uv run nox -s lints tests
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The live suite calls SanMar's real services, read-only:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
SANMAR_LIVE=1 SANMAR_CUSTOMER_NUMBER=... SANMAR_USERNAME=... SANMAR_PASSWORD=... \
|
|
100
|
+
uv run pytest tests/live
|
|
101
|
+
```
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# idi-sanmar-sdk
|
|
2
|
+
|
|
3
|
+
A typed Python SDK for [SanMar](https://www.sanmar.com): its own web services, its
|
|
4
|
+
PromoStandards web services, and the data and order files on its SFTP server.
|
|
5
|
+
|
|
6
|
+
- **Strict in, typed out.** Orders and lookups are validated Pydantic models, so a bad
|
|
7
|
+
address or a comma in a PO number fails before anything is sent. Responses come back as
|
|
8
|
+
typed records, with prices as `Decimal`.
|
|
9
|
+
- **No SOAP or file layouts to learn.** Each call is shaped into SanMar's payload in one
|
|
10
|
+
place, including the PromoStandards fields SanMar requires and then ignores.
|
|
11
|
+
- **Streams.** SFTP files are parsed row by row as they download, zip archives included.
|
|
12
|
+
|
|
13
|
+
Documentation: https://impressdesigns.dev/sanmar-sdk/
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
uv add idi-sanmar-sdk
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Python 3.14 or newer. SanMar's web services listen on port 8080, and its SFTP server on
|
|
22
|
+
port 2200; some networks block both.
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from sanmar_sdk import SanMar, ShipMethod, ShipTo
|
|
28
|
+
from sanmar_sdk.promostandards import PromoStandardsOrder, PromoStandardsOrderLine
|
|
29
|
+
|
|
30
|
+
sanmar = SanMar(123456, "sanmar-username", "sanmar-password")
|
|
31
|
+
|
|
32
|
+
# Look a style up, and check stock for one of its parts.
|
|
33
|
+
product = sanmar.promostandards.product_data.get("K500")
|
|
34
|
+
[stock] = sanmar.promostandards.inventory.get("K500", part_ids=["208284"])
|
|
35
|
+
|
|
36
|
+
# Place an order.
|
|
37
|
+
order = PromoStandardsOrder(
|
|
38
|
+
po_number="85496",
|
|
39
|
+
ship_to=ShipTo(address1="1404 W MAIN ST", city="CARROLLTON", state="TX", zip_code="75006"),
|
|
40
|
+
ship_method=ShipMethod.UPS_GROUND,
|
|
41
|
+
lines=[PromoStandardsOrderLine(part_id="208284", quantity=12)],
|
|
42
|
+
)
|
|
43
|
+
transaction_id = sanmar.promostandards.purchase_orders.send(order)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Reading the catalog over SFTP:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from sanmar_sdk.ftp import SanMarFTP
|
|
50
|
+
|
|
51
|
+
with SanMarFTP(123456, "ftp-password", host_key="ssh-rsa AAAA...") as ftp, ftp.catalog() as products:
|
|
52
|
+
for product in products:
|
|
53
|
+
print(product.style, product.catalog_color, product.size, product.unique_key)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Colors in requests and orders are SanMar's **catalog** colors (`SANMAR_MAINFRAME_COLOR`),
|
|
57
|
+
not the display names in `COLOR_NAME`. The SDK calls them `catalog_color` everywhere; see
|
|
58
|
+
[Identifiers](https://impressdesigns.dev/sanmar-sdk/identifiers/).
|
|
59
|
+
|
|
60
|
+
## Sources
|
|
61
|
+
|
|
62
|
+
Written against these revisions of SanMar's integration guides:
|
|
63
|
+
|
|
64
|
+
| Guide | Version | Updated |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| [SanMar Web Services Integration Guide](https://www.sanmar.com/medias/sys_master/pdf/h7a/ha0/33815499964446/SanMar-Web-Services-Integration-Guide-24.6/SanMar-Web-Services-Integration-Guide-24.6.pdf) | 24.6 | August 2026 |
|
|
67
|
+
| [SanMar Purchase Order Integration Guide](https://www.sanmar.com/medias/sys_master/root/h4b/ha7/33815499767838/SanMar-Purchase-Order-Integration-Guide-24.5/SanMar-Purchase-Order-Integration-Guide-24.5.pdf) | 24.5 | August 2026 |
|
|
68
|
+
| [SanMar FTP Integration Guide](https://www.sanmar.com/medias/sys_master/root/h08/hae/33815499571230/SanMar-FTP-Integration-Guide-v23.6/SanMar-FTP-Integration-Guide-v23.6.pdf) | 23.6 | August 2026 |
|
|
69
|
+
|
|
70
|
+
Where the guides contradict themselves, the SDK follows SanMar's own WSDLs and file
|
|
71
|
+
headers, recorded in `tests/`. The
|
|
72
|
+
[Sources](https://impressdesigns.dev/sanmar-sdk/sources/) page lists each case.
|
|
73
|
+
|
|
74
|
+
## Development
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
uv sync --all-groups
|
|
78
|
+
uv run nox -s lints tests
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The live suite calls SanMar's real services, read-only:
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
SANMAR_LIVE=1 SANMAR_CUSTOMER_NUMBER=... SANMAR_USERNAME=... SANMAR_PASSWORD=... \
|
|
85
|
+
uv run pytest tests/live
|
|
86
|
+
```
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "idi-sanmar-sdk"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "A typed Python SDK for SanMar’s web services and SFTP files."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"paramiko>=4.0,<5",
|
|
9
|
+
"pydantic>=2.12",
|
|
10
|
+
"zeep>=4.3.3",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[[project.authors]]
|
|
14
|
+
name = "Bradley Reynolds"
|
|
15
|
+
email = "bradley.reynolds@impressdesigns.com"
|
|
16
|
+
|
|
17
|
+
[project.license]
|
|
18
|
+
text = "MIT"
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
repository = "https://github.com/impressdesigns/sanmar-sdk"
|
|
22
|
+
documentation = "https://impressdesigns.dev/sanmar-sdk/"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["uv_build"]
|
|
26
|
+
build-backend = "uv_build"
|
|
27
|
+
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = [
|
|
30
|
+
"nox>=2026.8.10",
|
|
31
|
+
"prek>=0.4.14",
|
|
32
|
+
"ruff>=0.16.3",
|
|
33
|
+
"mypy>=2.3.1",
|
|
34
|
+
"ty>=0.0.72",
|
|
35
|
+
"types-paramiko>=4.0,<5",
|
|
36
|
+
"types-requests>=2.32",
|
|
37
|
+
]
|
|
38
|
+
docs = [
|
|
39
|
+
"sphinx>=9.1.0",
|
|
40
|
+
"furo>=2025.12.19",
|
|
41
|
+
"sphinx-autoapi>=3.8.0",
|
|
42
|
+
"releases>=2.1.1",
|
|
43
|
+
]
|
|
44
|
+
tests = [
|
|
45
|
+
"coverage>=7.15.2",
|
|
46
|
+
"pytest>=9.1.1",
|
|
47
|
+
"pytest-randomly>=4.1.0",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[tool.uv]
|
|
51
|
+
required-version = ">=0.11.8"
|
|
52
|
+
exclude-newer = "P7D"
|
|
53
|
+
environments = ["sys_platform == 'linux' and platform_machine == 'x86_64'"]
|
|
54
|
+
|
|
55
|
+
[tool.uv.build-backend]
|
|
56
|
+
module-name = "sanmar_sdk"
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 120
|
|
60
|
+
cache-dir = ".cache/ruff"
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = ["ALL"]
|
|
64
|
+
ignore = [
|
|
65
|
+
"COM812",
|
|
66
|
+
"CPY001",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint.extend-per-file-ignores]
|
|
70
|
+
"docs/*" = ["INP001"]
|
|
71
|
+
"scripts/*" = [
|
|
72
|
+
"INP001",
|
|
73
|
+
"T201",
|
|
74
|
+
]
|
|
75
|
+
"tests/*" = ["S101"]
|
|
76
|
+
|
|
77
|
+
[tool.ruff.lint.flake8-type-checking]
|
|
78
|
+
runtime-evaluated-base-classes = [
|
|
79
|
+
"pydantic.BaseModel",
|
|
80
|
+
"sanmar_sdk.base.Model",
|
|
81
|
+
"sanmar_sdk.base.Record",
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
[tool.ruff.lint.isort]
|
|
85
|
+
known-first-party = ["sanmar_sdk"]
|
|
86
|
+
|
|
87
|
+
[tool.ruff.lint.pydocstyle]
|
|
88
|
+
convention = "numpy"
|
|
89
|
+
|
|
90
|
+
[tool.ty.rules]
|
|
91
|
+
unused-ignore-comment = "ignore"
|
|
92
|
+
|
|
93
|
+
[tool.mypy]
|
|
94
|
+
pretty = true
|
|
95
|
+
num_workers = 4
|
|
96
|
+
native_parser = true
|
|
97
|
+
plugins = ["pydantic.mypy"]
|
|
98
|
+
cache_dir = ".cache/mypy"
|
|
99
|
+
untyped_calls_exclude = ["zeep"]
|
|
100
|
+
|
|
101
|
+
[tool.coverage.run]
|
|
102
|
+
branch = true
|
|
103
|
+
source_pkgs = ["sanmar_sdk"]
|
|
104
|
+
source_dirs = ["tests"]
|
|
105
|
+
data_file = ".cache/coverage/.coverage"
|
|
106
|
+
omit = [
|
|
107
|
+
"tests/live/*",
|
|
108
|
+
"src/sanmar_sdk/*/_wire.py",
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
[tool.pytest]
|
|
112
|
+
strict = true
|
|
113
|
+
cache_dir = ".cache/pytest"
|
|
114
|
+
markers = ["live: calls SanMar's real services; runs only when SANMAR_LIVE=1 (see tests/live/conftest.py)"]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "idi-sanmar-sdk"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "A typed Python SDK for SanMar’s web services and SFTP files."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Bradley Reynolds", email = "bradley.reynolds@impressdesigns.com" },
|
|
8
|
+
]
|
|
9
|
+
license = { text = "MIT" }
|
|
10
|
+
requires-python = ">=3.14"
|
|
11
|
+
dependencies = [
|
|
12
|
+
# 5.0 removed the ssh-rsa host key algorithm, and SanMar's SFTP server offers nothing newer.
|
|
13
|
+
# That is why 4.x carries GHSA-r374-rxx8-8654 (SHA-1 in rsakey.py), with no fix short of 5.0.
|
|
14
|
+
"paramiko>=4.0,<5",
|
|
15
|
+
"pydantic>=2.12",
|
|
16
|
+
"zeep>=4.3.3",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
repository = "https://github.com/impressdesigns/sanmar-sdk"
|
|
21
|
+
documentation = "https://impressdesigns.dev/sanmar-sdk/"
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["uv_build"]
|
|
25
|
+
build-backend = "uv_build"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = [
|
|
29
|
+
# DX
|
|
30
|
+
"nox>=2026.8.10",
|
|
31
|
+
"prek>=0.4.14",
|
|
32
|
+
# Linters
|
|
33
|
+
"ruff>=0.16.3",
|
|
34
|
+
"mypy>=2.3.1",
|
|
35
|
+
"ty>=0.0.72",
|
|
36
|
+
# Stubs
|
|
37
|
+
"types-paramiko>=4.0,<5",
|
|
38
|
+
"types-requests>=2.32",
|
|
39
|
+
]
|
|
40
|
+
docs = [
|
|
41
|
+
"sphinx>=9.1.0",
|
|
42
|
+
# Theme
|
|
43
|
+
"furo>=2025.12.19",
|
|
44
|
+
# Extensions
|
|
45
|
+
"sphinx-autoapi>=3.8.0",
|
|
46
|
+
"releases>=2.1.1",
|
|
47
|
+
]
|
|
48
|
+
tests = [
|
|
49
|
+
"coverage>=7.15.2",
|
|
50
|
+
"pytest>=9.1.1",
|
|
51
|
+
# Extensions
|
|
52
|
+
"pytest-randomly>=4.1.0",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[tool.uv]
|
|
56
|
+
# Sync with Dependabot's uv version
|
|
57
|
+
required-version = ">=0.11.8"
|
|
58
|
+
exclude-newer = "P7D"
|
|
59
|
+
environments = [
|
|
60
|
+
"sys_platform == 'linux' and platform_machine == 'x86_64'",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
[tool.uv.build-backend]
|
|
64
|
+
module-name = "sanmar_sdk"
|
|
65
|
+
|
|
66
|
+
[tool.ruff]
|
|
67
|
+
line-length = 120
|
|
68
|
+
cache-dir = ".cache/ruff"
|
|
69
|
+
|
|
70
|
+
[tool.ruff.lint]
|
|
71
|
+
select = ["ALL"]
|
|
72
|
+
ignore = [
|
|
73
|
+
"COM812", # (missing-trailing-comma) - Conflicts with formatter
|
|
74
|
+
"CPY001", # (Missing copyright notice at top of file) - No license
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
[tool.ruff.lint.extend-per-file-ignores]
|
|
78
|
+
"docs/*" = [
|
|
79
|
+
"INP001", # (File `docs/conf.py` is part of an implicit namespace package. Add an `__init__.py`.) - Docs are not modules
|
|
80
|
+
]
|
|
81
|
+
"scripts/*" = [
|
|
82
|
+
"INP001", # (File `scripts/*.py` is part of an implicit namespace package. Add an `__init__.py`.) - Scripts are not modules
|
|
83
|
+
"T201", # (`print` found) - Scripts report to the terminal
|
|
84
|
+
]
|
|
85
|
+
"tests/*" = [
|
|
86
|
+
"S101", # (Use of `assert` detected) - Yes, that's the point
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
[tool.ruff.lint.flake8-type-checking]
|
|
90
|
+
# Pydantic evaluates annotations at runtime to build validators, so imports used only
|
|
91
|
+
# in model annotations must stay at runtime scope. Without this, py314's deferred
|
|
92
|
+
# annotations make TC rules think they are safe to move into a type-checking block.
|
|
93
|
+
# Ruff cannot follow inheritance across modules, so the SDK's own bases are listed too.
|
|
94
|
+
runtime-evaluated-base-classes = [
|
|
95
|
+
"pydantic.BaseModel",
|
|
96
|
+
"sanmar_sdk.base.Model",
|
|
97
|
+
"sanmar_sdk.base.Record",
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
[tool.ruff.lint.isort]
|
|
101
|
+
known-first-party = ["sanmar_sdk"]
|
|
102
|
+
|
|
103
|
+
[tool.ruff.lint.pydocstyle]
|
|
104
|
+
convention = "numpy"
|
|
105
|
+
|
|
106
|
+
# Still need to tell Mypy to ignore these
|
|
107
|
+
[tool.ty.rules]
|
|
108
|
+
unused-ignore-comment = "ignore"
|
|
109
|
+
|
|
110
|
+
[tool.mypy]
|
|
111
|
+
pretty = true
|
|
112
|
+
num_workers = 4
|
|
113
|
+
native_parser = true # required by num_workers
|
|
114
|
+
plugins = ["pydantic.mypy"]
|
|
115
|
+
cache_dir = ".cache/mypy"
|
|
116
|
+
# zeep ships py.typed but leaves most of its API unannotated.
|
|
117
|
+
untyped_calls_exclude = ["zeep"]
|
|
118
|
+
|
|
119
|
+
[tool.coverage.run]
|
|
120
|
+
branch = true
|
|
121
|
+
source_pkgs = ["sanmar_sdk"]
|
|
122
|
+
source_dirs = ["tests"]
|
|
123
|
+
data_file = ".cache/coverage/.coverage"
|
|
124
|
+
omit = [
|
|
125
|
+
"tests/live/*",
|
|
126
|
+
# TypedDicts for type checkers only; nothing imports them at runtime.
|
|
127
|
+
"src/sanmar_sdk/*/_wire.py",
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
[tool.pytest]
|
|
131
|
+
strict = true
|
|
132
|
+
cache_dir = ".cache/pytest"
|
|
133
|
+
markers = [
|
|
134
|
+
"live: calls SanMar's real services; runs only when SANMAR_LIVE=1 (see tests/live/conftest.py)",
|
|
135
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""A typed Python SDK for SanMar's web services and SFTP data files."""
|
|
2
|
+
|
|
3
|
+
from .client import SanMar
|
|
4
|
+
from .common import (
|
|
5
|
+
Environment,
|
|
6
|
+
ShipMethod,
|
|
7
|
+
ShipTo,
|
|
8
|
+
SkuKey,
|
|
9
|
+
StyleColorSize,
|
|
10
|
+
StyleQuery,
|
|
11
|
+
Warehouse,
|
|
12
|
+
WarehouseNumber,
|
|
13
|
+
WillCall,
|
|
14
|
+
)
|
|
15
|
+
from .exceptions import (
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
AuthorizationError,
|
|
18
|
+
FileFormatError,
|
|
19
|
+
NotFoundError,
|
|
20
|
+
RequestError,
|
|
21
|
+
ResponseError,
|
|
22
|
+
SanMarConnectionError,
|
|
23
|
+
SanMarError,
|
|
24
|
+
ServiceError,
|
|
25
|
+
SoapFaultError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AuthenticationError",
|
|
30
|
+
"AuthorizationError",
|
|
31
|
+
"Environment",
|
|
32
|
+
"FileFormatError",
|
|
33
|
+
"NotFoundError",
|
|
34
|
+
"RequestError",
|
|
35
|
+
"ResponseError",
|
|
36
|
+
"SanMar",
|
|
37
|
+
"SanMarConnectionError",
|
|
38
|
+
"SanMarError",
|
|
39
|
+
"ServiceError",
|
|
40
|
+
"ShipMethod",
|
|
41
|
+
"ShipTo",
|
|
42
|
+
"SkuKey",
|
|
43
|
+
"SoapFaultError",
|
|
44
|
+
"StyleColorSize",
|
|
45
|
+
"StyleQuery",
|
|
46
|
+
"Warehouse",
|
|
47
|
+
"WarehouseNumber",
|
|
48
|
+
"WillCall",
|
|
49
|
+
]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Recording and replaying SanMar's WSDLs, so tests run through zeep without SanMar.
|
|
2
|
+
|
|
3
|
+
``scripts/snapshot_wsdls.py`` records every WSDL and schema document zeep loads into a
|
|
4
|
+
directory with an ``index.json``. Tests load that directory into :class:`ReplayTransport`,
|
|
5
|
+
which serves the documents back to zeep and answers each call with a queued response, so a
|
|
6
|
+
payload is validated against SanMar's real schema and a response is parsed exactly as it
|
|
7
|
+
would be in production.
|
|
8
|
+
|
|
9
|
+
Documents are keyed by path and query only, without the host, so one snapshot serves both
|
|
10
|
+
environments and survives SanMar writing an internal hostname into a schema import.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from collections import deque
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
from urllib.parse import urlsplit
|
|
19
|
+
|
|
20
|
+
import requests
|
|
21
|
+
from zeep import Transport
|
|
22
|
+
from zeep.wsdl.utils import etree_to_string
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
INDEX_FILE = "index.json"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def document_key(url: str) -> str:
|
|
31
|
+
"""Return the host-independent key a document is stored under."""
|
|
32
|
+
parts = urlsplit(url)
|
|
33
|
+
return f"{parts.path}?{parts.query}" if parts.query else parts.path
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class WsdlSnapshot:
|
|
38
|
+
"""WSDL and schema documents, keyed by :func:`document_key`."""
|
|
39
|
+
|
|
40
|
+
documents: dict[str, bytes] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def load(cls, directory: Path) -> WsdlSnapshot:
|
|
44
|
+
"""Load a snapshot written by :meth:`save`."""
|
|
45
|
+
index = json.loads((directory / INDEX_FILE).read_text(encoding="utf-8"))
|
|
46
|
+
return cls({key: (directory / name).read_bytes() for key, name in index["documents"].items()})
|
|
47
|
+
|
|
48
|
+
def save(self, directory: Path, **metadata: Any) -> None: # noqa: ANN401 - free-form, JSON-serializable
|
|
49
|
+
"""Write every document to ``directory``, alongside an index of their keys."""
|
|
50
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
names: dict[str, str] = {}
|
|
52
|
+
for key in sorted(self.documents):
|
|
53
|
+
stem = re.sub(r"[^A-Za-z0-9]+", "_", key).strip("_")
|
|
54
|
+
name = f"{stem}.xml"
|
|
55
|
+
suffix = 1
|
|
56
|
+
while name in names.values():
|
|
57
|
+
suffix += 1
|
|
58
|
+
name = f"{stem}_{suffix}.xml"
|
|
59
|
+
names[key] = name
|
|
60
|
+
(directory / name).write_bytes(self.documents[key])
|
|
61
|
+
index = {**metadata, "documents": names}
|
|
62
|
+
(directory / INDEX_FILE).write_text(json.dumps(index, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RecordingTransport(Transport):
|
|
66
|
+
"""A transport that keeps a copy of every document zeep loads."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, **kwargs: Any) -> None: # noqa: ANN401 - forwarded to zeep's Transport
|
|
69
|
+
"""Create the transport; keyword arguments go to zeep's :class:`~zeep.Transport`."""
|
|
70
|
+
super().__init__(**kwargs) # type: ignore[no-untyped-call] # mypy's untyped_calls_exclude misses super()
|
|
71
|
+
self.snapshot = WsdlSnapshot()
|
|
72
|
+
|
|
73
|
+
def load(self, url: str) -> bytes:
|
|
74
|
+
"""Load a document as usual, and record it."""
|
|
75
|
+
content: bytes = super().load(url) # type: ignore[no-untyped-call]
|
|
76
|
+
self.snapshot.documents[document_key(url)] = content
|
|
77
|
+
return content
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ReplayTransport(Transport):
|
|
81
|
+
"""A transport that serves recorded documents and answers calls from a queue.
|
|
82
|
+
|
|
83
|
+
Every envelope zeep sends is kept in :attr:`sent`, as ``(address, body)``.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, snapshot: WsdlSnapshot) -> None:
|
|
87
|
+
"""Serve documents from ``snapshot``; no call is answered until one is queued."""
|
|
88
|
+
super().__init__() # type: ignore[no-untyped-call]
|
|
89
|
+
self.snapshot = snapshot
|
|
90
|
+
self.sent: list[tuple[str, bytes]] = []
|
|
91
|
+
self._responses: deque[tuple[int, bytes]] = deque()
|
|
92
|
+
|
|
93
|
+
def queue(self, content: bytes | str, status: int = 200) -> None:
|
|
94
|
+
"""Queue the response to the next call."""
|
|
95
|
+
self._responses.append((status, content.encode() if isinstance(content, str) else content))
|
|
96
|
+
|
|
97
|
+
def load(self, url: str) -> bytes:
|
|
98
|
+
"""Serve a recorded document."""
|
|
99
|
+
try:
|
|
100
|
+
return self.snapshot.documents[document_key(url)]
|
|
101
|
+
except KeyError:
|
|
102
|
+
message = f"No recorded document for {url}; re-run scripts/snapshot_wsdls.py."
|
|
103
|
+
raise LookupError(message) from None
|
|
104
|
+
|
|
105
|
+
def post_xml(self, address: str, envelope: Any, headers: dict[str, str]) -> requests.Response: # noqa: ANN401, ARG002 - zeep's signature
|
|
106
|
+
"""Record the envelope and answer with the next queued response."""
|
|
107
|
+
self.sent.append((address, etree_to_string(envelope)))
|
|
108
|
+
if not self._responses:
|
|
109
|
+
message = f"No response queued for the call to {address}."
|
|
110
|
+
raise LookupError(message)
|
|
111
|
+
status, content = self._responses.popleft()
|
|
112
|
+
response = requests.Response()
|
|
113
|
+
response.status_code = status
|
|
114
|
+
response._content = content # noqa: SLF001 - building a canned response
|
|
115
|
+
response.headers["Content-Type"] = "text/xml; charset=utf-8"
|
|
116
|
+
response.encoding = "utf-8"
|
|
117
|
+
response.url = address
|
|
118
|
+
return response
|