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.
Files changed (43) hide show
  1. idi_sanmar_sdk-1.0.0/PKG-INFO +101 -0
  2. idi_sanmar_sdk-1.0.0/README.md +86 -0
  3. idi_sanmar_sdk-1.0.0/pyproject.toml +114 -0
  4. idi_sanmar_sdk-1.0.0/pyproject.toml.orig +135 -0
  5. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/__init__.py +49 -0
  6. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/_snapshots.py +118 -0
  7. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/_soap.py +287 -0
  8. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/base.py +156 -0
  9. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/client.py +73 -0
  10. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/common.py +213 -0
  11. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/exceptions.py +76 -0
  12. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/ftp/__init__.py +59 -0
  13. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/ftp/catalog.py +284 -0
  14. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/ftp/client.py +414 -0
  15. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/ftp/inventory.py +114 -0
  16. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/ftp/legacy.py +124 -0
  17. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/ftp/orders.py +253 -0
  18. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/ftp/pricing.py +96 -0
  19. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/ftp/readers.py +138 -0
  20. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/orders.py +74 -0
  21. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/__init__.py +107 -0
  22. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/_common.py +147 -0
  23. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/_wire.py +260 -0
  24. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/client.py +38 -0
  25. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/inventory.py +125 -0
  26. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/invoices.py +146 -0
  27. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/media_content.py +92 -0
  28. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/order_status.py +249 -0
  29. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/pricing.py +113 -0
  30. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/product_data.py +309 -0
  31. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/purchase_orders.py +183 -0
  32. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/promostandards/shipment_notification.py +156 -0
  33. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/py.typed +0 -0
  34. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/sources.py +46 -0
  35. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/standard/__init__.py +40 -0
  36. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/standard/_common.py +70 -0
  37. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/standard/_wire.py +185 -0
  38. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/standard/inventory.py +100 -0
  39. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/standard/invoices.py +210 -0
  40. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/standard/packing_slips.py +119 -0
  41. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/standard/pricing.py +79 -0
  42. idi_sanmar_sdk-1.0.0/src/sanmar_sdk/standard/products.py +192 -0
  43. 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