shelfshift 1.0.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.
- shelfshift/__init__.py +48 -0
- shelfshift/cli/__init__.py +1 -0
- shelfshift/cli/main.py +190 -0
- shelfshift/config.py +72 -0
- shelfshift/core/__init__.py +79 -0
- shelfshift/core/api.py +227 -0
- shelfshift/core/canonical/__init__.py +60 -0
- shelfshift/core/canonical/entities.py +643 -0
- shelfshift/core/canonical/helpers.py +256 -0
- shelfshift/core/canonical/schemas.py +21 -0
- shelfshift/core/canonical/serialization.py +17 -0
- shelfshift/core/config.py +32 -0
- shelfshift/core/detect/__init__.py +4 -0
- shelfshift/core/detect/csv.py +5 -0
- shelfshift/core/detect/url.py +134 -0
- shelfshift/core/exporters/__init__.py +42 -0
- shelfshift/core/exporters/api.py +51 -0
- shelfshift/core/exporters/platforms/__init__.py +2 -0
- shelfshift/core/exporters/platforms/bigcommerce.py +486 -0
- shelfshift/core/exporters/platforms/shopify.py +216 -0
- shelfshift/core/exporters/platforms/squarespace.py +166 -0
- shelfshift/core/exporters/platforms/wix.py +310 -0
- shelfshift/core/exporters/platforms/woocommerce.py +332 -0
- shelfshift/core/exporters/shared/__init__.py +2 -0
- shelfshift/core/exporters/shared/batch.py +164 -0
- shelfshift/core/exporters/shared/utils.py +302 -0
- shelfshift/core/exporters/shared/weight_units.py +36 -0
- shelfshift/core/importers/__init__.py +10 -0
- shelfshift/core/importers/csv/__init__.py +58 -0
- shelfshift/core/importers/csv/batch.py +310 -0
- shelfshift/core/importers/csv/bigcommerce.py +226 -0
- shelfshift/core/importers/csv/common.py +348 -0
- shelfshift/core/importers/csv/detection.py +37 -0
- shelfshift/core/importers/csv/shopify.py +122 -0
- shelfshift/core/importers/csv/squarespace.py +113 -0
- shelfshift/core/importers/csv/wix.py +125 -0
- shelfshift/core/importers/csv/woocommerce.py +139 -0
- shelfshift/core/importers/url/__init__.py +105 -0
- shelfshift/core/importers/url/api.py +93 -0
- shelfshift/core/importers/url/common.py +303 -0
- shelfshift/core/importers/url/platforms/__init__.py +20 -0
- shelfshift/core/importers/url/platforms/aliexpress.py +404 -0
- shelfshift/core/importers/url/platforms/amazon.py +308 -0
- shelfshift/core/importers/url/platforms/shopify.py +391 -0
- shelfshift/core/importers/url/platforms/squarespace.py +829 -0
- shelfshift/core/importers/url/platforms/woocommerce.py +694 -0
- shelfshift/core/registry.py +98 -0
- shelfshift/core/validate/__init__.py +4 -0
- shelfshift/core/validate/report.py +21 -0
- shelfshift/core/validate/rules.py +33 -0
- shelfshift/server/__init__.py +3 -0
- shelfshift/server/config.py +15 -0
- shelfshift/server/helpers/__init__.py +40 -0
- shelfshift/server/helpers/exporting.py +169 -0
- shelfshift/server/helpers/importing.py +150 -0
- shelfshift/server/helpers/payload.py +46 -0
- shelfshift/server/helpers/rendering.py +94 -0
- shelfshift/server/logging/__init__.py +3 -0
- shelfshift/server/logging/product_payloads.py +183 -0
- shelfshift/server/main.py +62 -0
- shelfshift/server/routers/__init__.py +3 -0
- shelfshift/server/routers/api.py +200 -0
- shelfshift/server/routers/web_csv.py +134 -0
- shelfshift/server/routers/web_url.py +314 -0
- shelfshift/server/schemas.py +81 -0
- shelfshift/server/web/static/app.js +904 -0
- shelfshift/server/web/static/favicon.ico +0 -0
- shelfshift/server/web/static/shelfshift_logo.png +0 -0
- shelfshift/server/web/static/styles.css +1077 -0
- shelfshift/server/web/templates/_export_form.html +59 -0
- shelfshift/server/web/templates/_product_editor.html +163 -0
- shelfshift/server/web/templates/_product_editor_batch.html +179 -0
- shelfshift/server/web/templates/base.html +78 -0
- shelfshift/server/web/templates/csv.html +85 -0
- shelfshift/server/web/templates/index.html +65 -0
- shelfshift/server/web/templates/url.html +78 -0
- shelfshift-1.0.0.dist-info/METADATA +381 -0
- shelfshift-1.0.0.dist-info/RECORD +82 -0
- shelfshift-1.0.0.dist-info/WHEEL +5 -0
- shelfshift-1.0.0.dist-info/entry_points.txt +3 -0
- shelfshift-1.0.0.dist-info/licenses/LICENSE +21 -0
- shelfshift-1.0.0.dist-info/top_level.txt +1 -0
shelfshift/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Public package entrypoint for the Shelfshift engine.
|
|
2
|
+
|
|
3
|
+
This package provides a stable import surface for core e-commerce catalog
|
|
4
|
+
import/export logic, plus optional frontend adapters (CLI and FastAPI server).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
_LAZY_EXPORTS: dict[str, tuple[str, str]] = {
|
|
11
|
+
"Product": ("shelfshift.core", "Product"),
|
|
12
|
+
"app": ("shelfshift.server.main", "app"),
|
|
13
|
+
"create_app": ("shelfshift.server.main", "create_app"),
|
|
14
|
+
"detect_csv_platform": ("shelfshift.core", "detect_csv_platform"),
|
|
15
|
+
"detect_product_url": ("shelfshift.core", "detect_product_url"),
|
|
16
|
+
"export_csv_for_target": ("shelfshift.core", "export_csv_for_target"),
|
|
17
|
+
"import_product_from_csv": ("shelfshift.core", "import_product_from_csv"),
|
|
18
|
+
"import_product_from_url": ("shelfshift.core", "import_product_from_url"),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
__version__ = version("shelfshift")
|
|
23
|
+
except PackageNotFoundError:
|
|
24
|
+
__version__ = "0.0.0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Product",
|
|
28
|
+
"__version__",
|
|
29
|
+
"app",
|
|
30
|
+
"create_app",
|
|
31
|
+
"detect_csv_platform",
|
|
32
|
+
"detect_product_url",
|
|
33
|
+
"export_csv_for_target",
|
|
34
|
+
"import_product_from_csv",
|
|
35
|
+
"import_product_from_url",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def __getattr__(name: str) -> Any:
|
|
40
|
+
target = _LAZY_EXPORTS.get(name)
|
|
41
|
+
if target is None:
|
|
42
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
43
|
+
|
|
44
|
+
module_name, attribute_name = target
|
|
45
|
+
module = __import__(module_name, fromlist=[attribute_name])
|
|
46
|
+
value = getattr(module, attribute_name)
|
|
47
|
+
globals()[name] = value
|
|
48
|
+
return value
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI frontend for the Shelfshift core engine."""
|
shelfshift/cli/main.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Command-line frontend for the Shelfshift core engine."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
|
|
11
|
+
from shelfshift.core.config import resolve_rapidapi_key
|
|
12
|
+
from shelfshift.core import (
|
|
13
|
+
convert_csv,
|
|
14
|
+
detect_csv,
|
|
15
|
+
detect_url,
|
|
16
|
+
export_csv,
|
|
17
|
+
import_csv,
|
|
18
|
+
import_url,
|
|
19
|
+
parse_product_payload,
|
|
20
|
+
validate,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
load_dotenv(Path(__file__).resolve().parents[2] / ".env")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _json_dump(data: Any) -> None:
|
|
27
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _cmd_detect_url(args: argparse.Namespace) -> int:
|
|
31
|
+
_json_dump(detect_url(args.input).__dict__)
|
|
32
|
+
return 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _cmd_detect_csv(args: argparse.Namespace) -> int:
|
|
36
|
+
_json_dump(detect_csv(args.input).__dict__)
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _cmd_import_url(args: argparse.Namespace) -> int:
|
|
41
|
+
resolved_rapidapi_key = resolve_rapidapi_key(args.rapidapi_key)
|
|
42
|
+
result = import_url(
|
|
43
|
+
args.url,
|
|
44
|
+
strict=args.strict,
|
|
45
|
+
rapidapi_key=resolved_rapidapi_key,
|
|
46
|
+
)
|
|
47
|
+
_json_dump(
|
|
48
|
+
{
|
|
49
|
+
"products": [p.to_dict(include_raw=args.include_raw) for p in result.products],
|
|
50
|
+
"errors": result.errors,
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _cmd_import_csv(args: argparse.Namespace) -> int:
|
|
57
|
+
result = import_csv(
|
|
58
|
+
args.input,
|
|
59
|
+
platform=args.source_platform,
|
|
60
|
+
strict=args.strict,
|
|
61
|
+
source_weight_unit=args.source_weight_unit,
|
|
62
|
+
)
|
|
63
|
+
_json_dump(
|
|
64
|
+
{
|
|
65
|
+
"products": [p.to_dict(include_raw=args.include_raw) for p in result.products],
|
|
66
|
+
"errors": result.errors,
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _cmd_convert(args: argparse.Namespace) -> int:
|
|
73
|
+
csv_bytes, report = convert_csv(
|
|
74
|
+
args.input,
|
|
75
|
+
target=args.to,
|
|
76
|
+
source=args.source,
|
|
77
|
+
strict=args.strict,
|
|
78
|
+
source_weight_unit=args.source_weight_unit,
|
|
79
|
+
export_options={"weight_unit": args.weight_unit},
|
|
80
|
+
)
|
|
81
|
+
out_path = Path(args.out)
|
|
82
|
+
out_path.write_bytes(csv_bytes)
|
|
83
|
+
if args.report:
|
|
84
|
+
Path(args.report).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
85
|
+
_json_dump({"output": str(out_path), "report": report})
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _cmd_validate(args: argparse.Namespace) -> int:
|
|
90
|
+
result = import_csv(
|
|
91
|
+
args.input,
|
|
92
|
+
platform=args.platform,
|
|
93
|
+
strict=args.strict,
|
|
94
|
+
source_weight_unit=args.source_weight_unit,
|
|
95
|
+
)
|
|
96
|
+
reports = validate(result.products)
|
|
97
|
+
payload = [
|
|
98
|
+
{
|
|
99
|
+
"valid": report.valid,
|
|
100
|
+
"issues": [issue.__dict__ for issue in report.issues],
|
|
101
|
+
}
|
|
102
|
+
for report in reports
|
|
103
|
+
]
|
|
104
|
+
if args.report:
|
|
105
|
+
Path(args.report).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
106
|
+
_json_dump(payload)
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
111
|
+
parser = argparse.ArgumentParser(prog="shelfshift", description="Shelfshift core engine CLI")
|
|
112
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
113
|
+
|
|
114
|
+
detect = subparsers.add_parser("detect", help="Detect input kind/platform from URL or CSV path")
|
|
115
|
+
detect.add_argument("input", help="URL or CSV file path")
|
|
116
|
+
detect.set_defaults(func=lambda args: _cmd_detect_csv(args) if Path(args.input).exists() else _cmd_detect_url(args))
|
|
117
|
+
|
|
118
|
+
import_url_cmd = subparsers.add_parser("import-url", help="Import canonical products from one or more URLs")
|
|
119
|
+
import_url_cmd.add_argument("url", nargs="+", help="One or more product URLs")
|
|
120
|
+
import_url_cmd.add_argument("--rapidapi-key", default=None)
|
|
121
|
+
import_url_cmd.add_argument("--include-raw", action="store_true")
|
|
122
|
+
import_url_cmd.add_argument("--strict", action="store_true")
|
|
123
|
+
import_url_cmd.set_defaults(func=lambda args: _cmd_import_url(argparse.Namespace(**{**vars(args), "url": args.url if len(args.url) > 1 else args.url[0]})))
|
|
124
|
+
|
|
125
|
+
convert = subparsers.add_parser("convert", help="Convert source CSV to target platform CSV")
|
|
126
|
+
convert.add_argument("input", help="Source CSV file path")
|
|
127
|
+
convert.add_argument("--to", required=True, choices=["shopify", "bigcommerce", "wix", "squarespace", "woocommerce"])
|
|
128
|
+
convert.add_argument("--source", default=None)
|
|
129
|
+
convert.add_argument("--source-weight-unit", default="")
|
|
130
|
+
convert.add_argument("--weight-unit", default="")
|
|
131
|
+
convert.add_argument("--strict", action="store_true")
|
|
132
|
+
convert.add_argument("--out", required=True)
|
|
133
|
+
convert.add_argument("--report", default="")
|
|
134
|
+
convert.set_defaults(func=_cmd_convert)
|
|
135
|
+
|
|
136
|
+
validate_cmd = subparsers.add_parser("validate", help="Validate canonicalized products imported from source CSV")
|
|
137
|
+
validate_cmd.add_argument("input", help="Source CSV file path")
|
|
138
|
+
validate_cmd.add_argument("--platform", default=None)
|
|
139
|
+
validate_cmd.add_argument("--source-weight-unit", default="")
|
|
140
|
+
validate_cmd.add_argument("--strict", action="store_true")
|
|
141
|
+
validate_cmd.add_argument("--report", default="")
|
|
142
|
+
validate_cmd.set_defaults(func=_cmd_validate)
|
|
143
|
+
|
|
144
|
+
import_csv_cmd = subparsers.add_parser("import-csv", help="Import canonical products from source CSV")
|
|
145
|
+
import_csv_cmd.add_argument("input", help="Source CSV file path")
|
|
146
|
+
import_csv_cmd.add_argument("--source-platform", default=None)
|
|
147
|
+
import_csv_cmd.add_argument("--source-weight-unit", default="")
|
|
148
|
+
import_csv_cmd.add_argument("--include-raw", action="store_true")
|
|
149
|
+
import_csv_cmd.add_argument("--strict", action="store_true")
|
|
150
|
+
import_csv_cmd.set_defaults(func=_cmd_import_csv)
|
|
151
|
+
|
|
152
|
+
export_csv_cmd = subparsers.add_parser("export-csv", help="Export canonical JSON payload to target CSV")
|
|
153
|
+
export_csv_cmd.add_argument("input", help="Canonical product JSON path")
|
|
154
|
+
export_csv_cmd.add_argument("--to", required=True, choices=["shopify", "bigcommerce", "wix", "squarespace", "woocommerce"])
|
|
155
|
+
export_csv_cmd.add_argument("--weight-unit", default="")
|
|
156
|
+
export_csv_cmd.add_argument("--out", required=True)
|
|
157
|
+
export_csv_cmd.add_argument("--report", default="")
|
|
158
|
+
export_csv_cmd.set_defaults(func=_cmd_export_csv)
|
|
159
|
+
|
|
160
|
+
return parser
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _cmd_export_csv(args: argparse.Namespace) -> int:
|
|
164
|
+
payload = json.loads(Path(args.input).read_text(encoding="utf-8"))
|
|
165
|
+
products = parse_product_payload(payload)
|
|
166
|
+
|
|
167
|
+
exported = export_csv(
|
|
168
|
+
products,
|
|
169
|
+
target=args.to,
|
|
170
|
+
options={"weight_unit": args.weight_unit},
|
|
171
|
+
)
|
|
172
|
+
Path(args.out).write_bytes(exported.csv_bytes)
|
|
173
|
+
report = {"filename": exported.filename, "target_platform": args.to}
|
|
174
|
+
if args.report:
|
|
175
|
+
Path(args.report).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
176
|
+
_json_dump({"output": args.out, "report": report})
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def main(argv: list[str] | None = None) -> int:
|
|
181
|
+
parser = build_parser()
|
|
182
|
+
args = parser.parse_args(argv)
|
|
183
|
+
try:
|
|
184
|
+
return int(args.func(args) or 0)
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
parser.exit(status=2, message=f"error: {exc}\n")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
raise SystemExit(main())
|
shelfshift/config.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Shared runtime settings for server/web adapters.
|
|
2
|
+
|
|
3
|
+
This module owns environment-backed application settings. It is intentionally
|
|
4
|
+
separate from ``shelfshift.core.config`` because core config stays minimal and
|
|
5
|
+
framework-agnostic.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Settings:
|
|
14
|
+
app_name: str
|
|
15
|
+
app_tagline: str
|
|
16
|
+
brand_primary: str
|
|
17
|
+
brand_secondary: str
|
|
18
|
+
brand_ink: str
|
|
19
|
+
debug: bool
|
|
20
|
+
log_verbosity: str
|
|
21
|
+
rapidapi_key: str | None
|
|
22
|
+
cors_allow_origins: tuple[str, ...]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _env_bool(name: str, default: bool = False) -> bool:
|
|
26
|
+
val = os.getenv(name)
|
|
27
|
+
if val is None:
|
|
28
|
+
return default
|
|
29
|
+
return val.strip().lower() in {"1", "true", "yes", "on"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _env_choice(name: str, default: str, *, allowed: set[str]) -> str:
|
|
33
|
+
val = os.getenv(name)
|
|
34
|
+
if val is None:
|
|
35
|
+
return default
|
|
36
|
+
normalized = val.strip().lower()
|
|
37
|
+
if normalized in allowed:
|
|
38
|
+
return normalized
|
|
39
|
+
return default
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def settings_from_env() -> Settings:
|
|
43
|
+
origins = tuple(
|
|
44
|
+
origin.strip()
|
|
45
|
+
for origin in os.getenv("CORS_ALLOW_ORIGINS", "*").split(",")
|
|
46
|
+
if origin.strip()
|
|
47
|
+
)
|
|
48
|
+
return Settings(
|
|
49
|
+
app_name=os.getenv("APP_NAME", "ShelfShift"),
|
|
50
|
+
app_tagline=os.getenv(
|
|
51
|
+
"APP_TAGLINE",
|
|
52
|
+
"Developer toolkit for ecommerce catalog translation.",
|
|
53
|
+
),
|
|
54
|
+
brand_primary=os.getenv("BRAND_PRIMARY", "#18d9b6"),
|
|
55
|
+
brand_secondary=os.getenv("BRAND_SECONDARY", "#27c6f5"),
|
|
56
|
+
brand_ink=os.getenv("BRAND_INK", "#020b1a"),
|
|
57
|
+
debug=_env_bool("DEBUG", default=False),
|
|
58
|
+
log_verbosity=_env_choice(
|
|
59
|
+
"LOG_VERBOSITY",
|
|
60
|
+
default="medium",
|
|
61
|
+
allowed={"low", "medium", "high", "extrahigh"},
|
|
62
|
+
),
|
|
63
|
+
rapidapi_key=os.getenv("RAPIDAPI_KEY"),
|
|
64
|
+
cors_allow_origins=origins or ("*",),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_settings() -> Settings:
|
|
69
|
+
return settings_from_env()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
__all__ = ["Settings", "get_settings", "settings_from_env"]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Core engine API.
|
|
2
|
+
|
|
3
|
+
The core layer is framework-agnostic and safe to import from scripts, tests,
|
|
4
|
+
CLI commands, and web frontends.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
_LAZY_EXPORTS: dict[str, tuple[str, str]] = {
|
|
10
|
+
"CoreConfig": ("shelfshift.core.config", "CoreConfig"),
|
|
11
|
+
"DetectResult": ("shelfshift.core.api", "DetectResult"),
|
|
12
|
+
"ExportResult": ("shelfshift.core.api", "ExportResult"),
|
|
13
|
+
"ImportResult": ("shelfshift.core.api", "ImportResult"),
|
|
14
|
+
"Product": ("shelfshift.core.canonical.entities", "Product"),
|
|
15
|
+
"config_from_env": ("shelfshift.core.config", "config_from_env"),
|
|
16
|
+
"convert_csv": ("shelfshift.core.api", "convert_csv"),
|
|
17
|
+
"detect_csv": ("shelfshift.core.api", "detect_csv"),
|
|
18
|
+
"detect_csv_platform": ("shelfshift.core.detect.csv", "detect_csv_platform"),
|
|
19
|
+
"detect_product_url": ("shelfshift.core.detect.url", "detect_product_url"),
|
|
20
|
+
"detect_url": ("shelfshift.core.api", "detect_url"),
|
|
21
|
+
"export_csv": ("shelfshift.core.api", "export_csv"),
|
|
22
|
+
"export_csv_for_target": ("shelfshift.core.exporters", "export_csv_for_target"),
|
|
23
|
+
"get_exporter": ("shelfshift.core.registry", "get_exporter"),
|
|
24
|
+
"get_importer": ("shelfshift.core.registry", "get_importer"),
|
|
25
|
+
"import_csv": ("shelfshift.core.api", "import_csv"),
|
|
26
|
+
"import_product_from_csv": ("shelfshift.core.importers.csv", "import_product_from_csv"),
|
|
27
|
+
"import_product_from_url": ("shelfshift.core.importers.url", "import_product_from_url"),
|
|
28
|
+
"import_products_from_csv": ("shelfshift.core.importers.csv", "import_products_from_csv"),
|
|
29
|
+
"import_products_from_urls": ("shelfshift.core.importers.url", "import_products_from_urls"),
|
|
30
|
+
"import_url": ("shelfshift.core.api", "import_url"),
|
|
31
|
+
"list_exporters": ("shelfshift.core.registry", "list_exporters"),
|
|
32
|
+
"list_importers": ("shelfshift.core.registry", "list_importers"),
|
|
33
|
+
"parse_product_payload": ("shelfshift.core.api", "parse_product_payload"),
|
|
34
|
+
"register_exporter": ("shelfshift.core.registry", "register_exporter"),
|
|
35
|
+
"register_importer": ("shelfshift.core.registry", "register_importer"),
|
|
36
|
+
"validate": ("shelfshift.core.api", "validate"),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"CoreConfig",
|
|
41
|
+
"DetectResult",
|
|
42
|
+
"ExportResult",
|
|
43
|
+
"ImportResult",
|
|
44
|
+
"Product",
|
|
45
|
+
"config_from_env",
|
|
46
|
+
"convert_csv",
|
|
47
|
+
"detect_csv",
|
|
48
|
+
"detect_csv_platform",
|
|
49
|
+
"detect_url",
|
|
50
|
+
"detect_product_url",
|
|
51
|
+
"export_csv",
|
|
52
|
+
"export_csv_for_target",
|
|
53
|
+
"get_exporter",
|
|
54
|
+
"get_importer",
|
|
55
|
+
"import_csv",
|
|
56
|
+
"import_product_from_csv",
|
|
57
|
+
"import_product_from_url",
|
|
58
|
+
"import_products_from_csv",
|
|
59
|
+
"import_products_from_urls",
|
|
60
|
+
"import_url",
|
|
61
|
+
"list_exporters",
|
|
62
|
+
"list_importers",
|
|
63
|
+
"parse_product_payload",
|
|
64
|
+
"register_exporter",
|
|
65
|
+
"register_importer",
|
|
66
|
+
"validate",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def __getattr__(name: str) -> Any:
|
|
71
|
+
target = _LAZY_EXPORTS.get(name)
|
|
72
|
+
if target is None:
|
|
73
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
74
|
+
|
|
75
|
+
module_name, attribute_name = target
|
|
76
|
+
module = __import__(module_name, fromlist=[attribute_name])
|
|
77
|
+
value = getattr(module, attribute_name)
|
|
78
|
+
globals()[name] = value
|
|
79
|
+
return value
|
shelfshift/core/api.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Stable public API facade for the Shelfshift core engine."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .canonical.entities import Product
|
|
9
|
+
from .config import CoreConfig, config_from_env, resolve_rapidapi_key
|
|
10
|
+
from .detect import detect_csv_platform as _detect_csv_platform
|
|
11
|
+
from .detect import detect_product_url as _detect_product_url
|
|
12
|
+
from .importers.csv.common import parse_canonical_product_payload
|
|
13
|
+
from .importers.csv import import_product_from_csv, import_products_from_csv
|
|
14
|
+
from .importers.url import import_product_from_url, import_products_from_urls
|
|
15
|
+
from .registry import get_exporter
|
|
16
|
+
from .validate import ValidationReport, validate_product
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class DetectResult:
|
|
21
|
+
kind: str
|
|
22
|
+
platform: str | None
|
|
23
|
+
is_product: bool
|
|
24
|
+
product_id: str | None = None
|
|
25
|
+
slug: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ImportResult:
|
|
30
|
+
products: list[Product] = field(default_factory=list)
|
|
31
|
+
errors: list[dict[str, str]] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ExportResult:
|
|
36
|
+
csv_bytes: bytes
|
|
37
|
+
filename: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def detect_url(url: str) -> DetectResult:
|
|
41
|
+
payload = _detect_product_url(url)
|
|
42
|
+
return DetectResult(
|
|
43
|
+
kind="url",
|
|
44
|
+
platform=payload.get("platform"),
|
|
45
|
+
is_product=bool(payload.get("is_product", False)),
|
|
46
|
+
product_id=payload.get("product_id"),
|
|
47
|
+
slug=payload.get("slug"),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def detect_csv(csv_input: bytes | str | Path) -> DetectResult:
|
|
52
|
+
csv_bytes = _coerce_bytes(csv_input)
|
|
53
|
+
platform = _detect_csv_platform(csv_bytes)
|
|
54
|
+
return DetectResult(kind="csv", platform=platform, is_product=False)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def import_url(
|
|
58
|
+
urls: str | list[str],
|
|
59
|
+
*,
|
|
60
|
+
strict: bool = False,
|
|
61
|
+
debug: bool = False,
|
|
62
|
+
rapidapi_key: str | None = None,
|
|
63
|
+
) -> ImportResult:
|
|
64
|
+
config = config_from_env(strict=strict, debug=debug)
|
|
65
|
+
resolved_rapidapi_key = resolve_rapidapi_key(rapidapi_key)
|
|
66
|
+
|
|
67
|
+
if isinstance(urls, str):
|
|
68
|
+
product = import_product_from_url(urls, rapidapi_key=resolved_rapidapi_key)
|
|
69
|
+
return ImportResult(products=[product], errors=[])
|
|
70
|
+
|
|
71
|
+
products, errors = import_products_from_urls(
|
|
72
|
+
list(urls),
|
|
73
|
+
rapidapi_key=resolved_rapidapi_key,
|
|
74
|
+
)
|
|
75
|
+
if config.strict and errors:
|
|
76
|
+
raise ValueError(f"Strict mode failed with {len(errors)} URL import error(s).")
|
|
77
|
+
return ImportResult(products=products, errors=errors)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def import_csv(
|
|
81
|
+
csv_input: bytes | str | Path,
|
|
82
|
+
*,
|
|
83
|
+
platform: str | None = None,
|
|
84
|
+
strict: bool = False,
|
|
85
|
+
source_weight_unit: str | None = None,
|
|
86
|
+
) -> ImportResult:
|
|
87
|
+
csv_bytes = _coerce_bytes(csv_input)
|
|
88
|
+
source_platform = (platform or _detect_csv_platform(csv_bytes)).strip().lower()
|
|
89
|
+
products = import_products_from_csv(
|
|
90
|
+
source_platform=source_platform,
|
|
91
|
+
csv_bytes=csv_bytes,
|
|
92
|
+
source_weight_unit=source_weight_unit,
|
|
93
|
+
)
|
|
94
|
+
if strict and not products:
|
|
95
|
+
raise ValueError("Strict mode failed: no products imported from CSV.")
|
|
96
|
+
return ImportResult(products=products, errors=[])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def export_csv(
|
|
100
|
+
products: Product | list[Product],
|
|
101
|
+
*,
|
|
102
|
+
target: str,
|
|
103
|
+
options: dict[str, Any] | None = None,
|
|
104
|
+
) -> ExportResult:
|
|
105
|
+
normalized_target = str(target).strip().lower()
|
|
106
|
+
opts = dict(options or {})
|
|
107
|
+
|
|
108
|
+
publish = bool(opts.get("publish", False))
|
|
109
|
+
weight_unit = str(opts.get("weight_unit", ""))
|
|
110
|
+
bigcommerce_csv_format = str(opts.get("bigcommerce_csv_format", "modern"))
|
|
111
|
+
squarespace_product_page = str(opts.get("squarespace_product_page", ""))
|
|
112
|
+
squarespace_product_url = str(opts.get("squarespace_product_url", ""))
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
exporter = get_exporter(normalized_target)
|
|
116
|
+
except KeyError as exc:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
"target_platform must be one of: shopify, bigcommerce, wix, squarespace, woocommerce"
|
|
119
|
+
) from exc
|
|
120
|
+
|
|
121
|
+
if isinstance(products, list):
|
|
122
|
+
from .exporters.shared.batch import (
|
|
123
|
+
products_to_bigcommerce_csv,
|
|
124
|
+
products_to_shopify_csv,
|
|
125
|
+
products_to_squarespace_csv,
|
|
126
|
+
products_to_wix_csv,
|
|
127
|
+
products_to_woocommerce_csv,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if normalized_target == "shopify":
|
|
131
|
+
csv_text, filename = products_to_shopify_csv(products, publish=publish, weight_unit=weight_unit)
|
|
132
|
+
elif normalized_target == "bigcommerce":
|
|
133
|
+
csv_text, filename = products_to_bigcommerce_csv(
|
|
134
|
+
products,
|
|
135
|
+
publish=publish,
|
|
136
|
+
csv_format=bigcommerce_csv_format,
|
|
137
|
+
weight_unit=weight_unit,
|
|
138
|
+
)
|
|
139
|
+
elif normalized_target == "wix":
|
|
140
|
+
csv_text, filename = products_to_wix_csv(products, publish=publish, weight_unit=weight_unit)
|
|
141
|
+
elif normalized_target == "squarespace":
|
|
142
|
+
csv_text, filename = products_to_squarespace_csv(
|
|
143
|
+
products,
|
|
144
|
+
publish=publish,
|
|
145
|
+
product_page=squarespace_product_page,
|
|
146
|
+
product_url=squarespace_product_url,
|
|
147
|
+
weight_unit=weight_unit,
|
|
148
|
+
)
|
|
149
|
+
elif normalized_target == "woocommerce":
|
|
150
|
+
csv_text, filename = products_to_woocommerce_csv(products, publish=publish, weight_unit=weight_unit)
|
|
151
|
+
else:
|
|
152
|
+
raise ValueError(f"Unsupported target platform: {normalized_target}")
|
|
153
|
+
return ExportResult(csv_bytes=csv_text.encode("utf-8"), filename=filename)
|
|
154
|
+
|
|
155
|
+
csv_text, filename = exporter(
|
|
156
|
+
products,
|
|
157
|
+
target_platform=normalized_target,
|
|
158
|
+
publish=publish,
|
|
159
|
+
weight_unit=weight_unit,
|
|
160
|
+
bigcommerce_csv_format=bigcommerce_csv_format,
|
|
161
|
+
squarespace_product_page=squarespace_product_page,
|
|
162
|
+
squarespace_product_url=squarespace_product_url,
|
|
163
|
+
)
|
|
164
|
+
return ExportResult(csv_bytes=csv_text.encode("utf-8"), filename=filename)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def convert_csv(
|
|
168
|
+
csv_input: bytes | str | Path,
|
|
169
|
+
*,
|
|
170
|
+
target: str,
|
|
171
|
+
source: str | None = None,
|
|
172
|
+
strict: bool = False,
|
|
173
|
+
source_weight_unit: str | None = None,
|
|
174
|
+
export_options: dict[str, Any] | None = None,
|
|
175
|
+
) -> tuple[bytes, dict[str, Any]]:
|
|
176
|
+
imported = import_csv(
|
|
177
|
+
csv_input,
|
|
178
|
+
platform=source,
|
|
179
|
+
strict=strict,
|
|
180
|
+
source_weight_unit=source_weight_unit,
|
|
181
|
+
)
|
|
182
|
+
exported = export_csv(imported.products, target=target, options=export_options)
|
|
183
|
+
|
|
184
|
+
report = {
|
|
185
|
+
"source_platform": source or _detect_csv_platform(_coerce_bytes(csv_input)),
|
|
186
|
+
"target_platform": str(target).strip().lower(),
|
|
187
|
+
"product_count": len(imported.products),
|
|
188
|
+
"errors": imported.errors,
|
|
189
|
+
"filename": exported.filename,
|
|
190
|
+
}
|
|
191
|
+
return exported.csv_bytes, report
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def validate(products: Product | list[Product]) -> list[ValidationReport]:
|
|
195
|
+
if isinstance(products, list):
|
|
196
|
+
return [validate_product(product) for product in products]
|
|
197
|
+
return [validate_product(products)]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def parse_product_payload(payload: dict[str, Any] | list[dict[str, Any]]) -> Product | list[Product]:
|
|
201
|
+
if isinstance(payload, list):
|
|
202
|
+
return [parse_canonical_product_payload(item) for item in payload]
|
|
203
|
+
return parse_canonical_product_payload(payload)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _coerce_bytes(value: bytes | str | Path) -> bytes:
|
|
207
|
+
if isinstance(value, bytes):
|
|
208
|
+
return value
|
|
209
|
+
path = Path(value)
|
|
210
|
+
return path.read_bytes()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
__all__ = [
|
|
214
|
+
"CoreConfig",
|
|
215
|
+
"DetectResult",
|
|
216
|
+
"ExportResult",
|
|
217
|
+
"ImportResult",
|
|
218
|
+
"config_from_env",
|
|
219
|
+
"convert_csv",
|
|
220
|
+
"detect_csv",
|
|
221
|
+
"detect_url",
|
|
222
|
+
"export_csv",
|
|
223
|
+
"import_csv",
|
|
224
|
+
"import_url",
|
|
225
|
+
"parse_product_payload",
|
|
226
|
+
"validate",
|
|
227
|
+
]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from .entities import (
|
|
2
|
+
CategorySet,
|
|
3
|
+
Currency,
|
|
4
|
+
Identifiers,
|
|
5
|
+
Inventory,
|
|
6
|
+
Media,
|
|
7
|
+
MediaType,
|
|
8
|
+
Money,
|
|
9
|
+
OptionDef,
|
|
10
|
+
OptionValue,
|
|
11
|
+
Price,
|
|
12
|
+
Product,
|
|
13
|
+
Seo,
|
|
14
|
+
SourceRef,
|
|
15
|
+
Variant,
|
|
16
|
+
Weight,
|
|
17
|
+
WeightUnit,
|
|
18
|
+
)
|
|
19
|
+
from .helpers import (
|
|
20
|
+
format_decimal,
|
|
21
|
+
normalize_currency,
|
|
22
|
+
parse_decimal_money,
|
|
23
|
+
resolve_all_image_urls,
|
|
24
|
+
resolve_current_money,
|
|
25
|
+
resolve_option_defs,
|
|
26
|
+
resolve_primary_image_url,
|
|
27
|
+
resolve_taxonomy_paths,
|
|
28
|
+
resolve_variant_option_values,
|
|
29
|
+
)
|
|
30
|
+
from .serialization import serialize_product_for_api, serialize_variant_for_api
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"Currency",
|
|
34
|
+
"CategorySet",
|
|
35
|
+
"Identifiers",
|
|
36
|
+
"Inventory",
|
|
37
|
+
"Media",
|
|
38
|
+
"MediaType",
|
|
39
|
+
"Money",
|
|
40
|
+
"OptionDef",
|
|
41
|
+
"OptionValue",
|
|
42
|
+
"Price",
|
|
43
|
+
"Product",
|
|
44
|
+
"Seo",
|
|
45
|
+
"SourceRef",
|
|
46
|
+
"Variant",
|
|
47
|
+
"Weight",
|
|
48
|
+
"WeightUnit",
|
|
49
|
+
"format_decimal",
|
|
50
|
+
"normalize_currency",
|
|
51
|
+
"parse_decimal_money",
|
|
52
|
+
"resolve_all_image_urls",
|
|
53
|
+
"resolve_current_money",
|
|
54
|
+
"resolve_option_defs",
|
|
55
|
+
"resolve_primary_image_url",
|
|
56
|
+
"resolve_taxonomy_paths",
|
|
57
|
+
"resolve_variant_option_values",
|
|
58
|
+
"serialize_product_for_api",
|
|
59
|
+
"serialize_variant_for_api",
|
|
60
|
+
]
|