paykit-sdk 0.1.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.
- commands/__init__.py +9 -0
- commands/add.py +138 -0
- commands/init.py +121 -0
- commands/set.py +47 -0
- core/__init__.py +11 -0
- core/config.py +183 -0
- core/fetcher.py +140 -0
- paykit_sdk-0.1.0.dist-info/METADATA +103 -0
- paykit_sdk-0.1.0.dist-info/RECORD +12 -0
- paykit_sdk-0.1.0.dist-info/WHEEL +5 -0
- paykit_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- paykit_sdk-0.1.0.dist-info/top_level.txt +2 -0
commands/__init__.py
ADDED
commands/add.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Add command — adds payment providers from CDN into the project.
|
|
3
|
+
|
|
4
|
+
paykit add → interactive: lists available providers, prompts
|
|
5
|
+
paykit add payme → latest version
|
|
6
|
+
paykit add payme@1.0.0 → specific version
|
|
7
|
+
paykit add payme click → multiple at once
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from paykit.core.config import Config
|
|
13
|
+
from paykit.core.fetcher import ProviderFetcher
|
|
14
|
+
from paykit.utils.parsers import parse_provider_string
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _pick_from_list(items: list, label: str) -> str:
|
|
18
|
+
"""Simple numbered prompt when user doesn't specify."""
|
|
19
|
+
click.echo(f"\nAvailable {label}:")
|
|
20
|
+
for i, item in enumerate(items, 1):
|
|
21
|
+
click.echo(f" {i}. {item}")
|
|
22
|
+
while True:
|
|
23
|
+
raw = click.prompt(f"Pick {label} (number or name)", default=items[0])
|
|
24
|
+
if raw in items:
|
|
25
|
+
return raw
|
|
26
|
+
try:
|
|
27
|
+
idx = int(raw) - 1
|
|
28
|
+
if 0 <= idx < len(items):
|
|
29
|
+
return items[idx]
|
|
30
|
+
except ValueError:
|
|
31
|
+
pass
|
|
32
|
+
click.echo(" Invalid choice, try again.")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@click.command()
|
|
36
|
+
@click.argument("providers", nargs=-1, required=False)
|
|
37
|
+
def add_command(providers: tuple):
|
|
38
|
+
"""
|
|
39
|
+
Add payment provider(s) to the project.
|
|
40
|
+
|
|
41
|
+
\b
|
|
42
|
+
Examples:
|
|
43
|
+
paykit add # interactive — shows available providers
|
|
44
|
+
paykit add payme # latest version
|
|
45
|
+
paykit add payme@1.0.0 # specific version
|
|
46
|
+
paykit add payme click # multiple
|
|
47
|
+
"""
|
|
48
|
+
config = Config()
|
|
49
|
+
|
|
50
|
+
if not config.config_exists():
|
|
51
|
+
click.echo("Error: paykit.json not found. Run 'paykit init' first.", err=True)
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
config.load_config()
|
|
56
|
+
except Exception as e:
|
|
57
|
+
click.echo(f"Error loading config: {e}", err=True)
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
framework = config.get_framework()
|
|
61
|
+
cdn_url = config.get_cdn_url()
|
|
62
|
+
fetcher = ProviderFetcher(cdn_url, config.library_dir)
|
|
63
|
+
|
|
64
|
+
# ── No providers specified → interactive discovery ────────────────────────
|
|
65
|
+
if not providers:
|
|
66
|
+
try:
|
|
67
|
+
available = fetcher.fetch_available_providers(framework)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
click.echo(f"Error fetching available providers: {e}", err=True)
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
if not available:
|
|
73
|
+
click.echo(f"No providers available for framework: {framework}")
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
chosen = _pick_from_list(available, "provider")
|
|
77
|
+
providers = (chosen,)
|
|
78
|
+
|
|
79
|
+
# ── Process each provider spec ────────────────────────────────────────────
|
|
80
|
+
added = []
|
|
81
|
+
failed = []
|
|
82
|
+
|
|
83
|
+
click.echo(f"\nFramework: {framework}\n")
|
|
84
|
+
|
|
85
|
+
for spec in providers:
|
|
86
|
+
provider_name, version = parse_provider_string(spec)
|
|
87
|
+
|
|
88
|
+
# If version not pinned, resolve to "latest" — but show available versions
|
|
89
|
+
if version == "latest":
|
|
90
|
+
try:
|
|
91
|
+
versions = fetcher.fetch_available_versions(framework, provider_name)
|
|
92
|
+
# Use latest (first entry per CDN convention) without prompting,
|
|
93
|
+
# unless it's interactive mode (single provider, no @version)
|
|
94
|
+
if len(providers) == 1 and len(versions) > 1:
|
|
95
|
+
version = _pick_from_list(versions, "version")
|
|
96
|
+
else:
|
|
97
|
+
version = versions[0] if versions else "latest"
|
|
98
|
+
except Exception:
|
|
99
|
+
version = "latest" # fall back to literal "latest"
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
click.echo(f"Adding {provider_name}@{version}...")
|
|
103
|
+
|
|
104
|
+
if config.is_provider_installed(provider_name):
|
|
105
|
+
click.echo(f" Removing existing installation...")
|
|
106
|
+
config.remove_provider_installation(provider_name)
|
|
107
|
+
|
|
108
|
+
click.echo(f" Fetching from CDN...")
|
|
109
|
+
fetcher.install_provider(
|
|
110
|
+
framework=framework,
|
|
111
|
+
provider_name=provider_name,
|
|
112
|
+
version=version,
|
|
113
|
+
force=True,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if fetcher.verify_installation(provider_name):
|
|
117
|
+
config.add_provider(provider_name, version)
|
|
118
|
+
added.append(f"{provider_name}@{version}")
|
|
119
|
+
click.echo(f" ✓ {provider_name}@{version} added\n")
|
|
120
|
+
else:
|
|
121
|
+
failed.append(spec)
|
|
122
|
+
click.echo(f" ✗ Verification failed\n", err=True)
|
|
123
|
+
|
|
124
|
+
except Exception as e:
|
|
125
|
+
failed.append(spec)
|
|
126
|
+
click.echo(f" ✗ Failed: {e}\n", err=True)
|
|
127
|
+
if config.is_provider_installed(provider_name):
|
|
128
|
+
config.remove_provider_installation(provider_name)
|
|
129
|
+
|
|
130
|
+
# ── Summary ───────────────────────────────────────────────────────────────
|
|
131
|
+
click.echo("─" * 40)
|
|
132
|
+
if added:
|
|
133
|
+
for p in added:
|
|
134
|
+
click.echo(f" ✓ {p}")
|
|
135
|
+
if failed:
|
|
136
|
+
for p in failed:
|
|
137
|
+
click.echo(f" ✗ {p} (failed)")
|
|
138
|
+
click.echo("─" * 40)
|
commands/init.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Init command implementation
|
|
3
|
+
|
|
4
|
+
Initializes PayKit configuration and synchronizes providers with library.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from paykit.core.config import Config
|
|
11
|
+
from paykit.core.fetcher import ProviderFetcher
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.command()
|
|
15
|
+
@click.option(
|
|
16
|
+
"--framework",
|
|
17
|
+
default="django",
|
|
18
|
+
help="Framework to use (default: django)"
|
|
19
|
+
)
|
|
20
|
+
@click.option(
|
|
21
|
+
"--reload",
|
|
22
|
+
is_flag=True,
|
|
23
|
+
help="Force reload all providers from CDN"
|
|
24
|
+
)
|
|
25
|
+
def init_command(framework: str, reload: bool):
|
|
26
|
+
"""
|
|
27
|
+
Initialize PayKit configuration
|
|
28
|
+
|
|
29
|
+
Creates paykit.json in the current directory and synchronizes
|
|
30
|
+
providers with the library installation.
|
|
31
|
+
"""
|
|
32
|
+
config = Config()
|
|
33
|
+
|
|
34
|
+
# Initialize or load configuration
|
|
35
|
+
if config.config_exists():
|
|
36
|
+
click.echo("Found existing paykit.json")
|
|
37
|
+
try:
|
|
38
|
+
config.load_config()
|
|
39
|
+
except Exception as e:
|
|
40
|
+
click.echo(f"Error loading config: {e}", err=True)
|
|
41
|
+
return
|
|
42
|
+
else:
|
|
43
|
+
click.echo("Initializing new paykit.json...")
|
|
44
|
+
config.initialize(framework=framework)
|
|
45
|
+
click.echo(f"✓ Created paykit.json with framework: {framework}")
|
|
46
|
+
|
|
47
|
+
# Get configuration details
|
|
48
|
+
cdn_url = config.get_cdn_url()
|
|
49
|
+
current_framework = config.get_framework()
|
|
50
|
+
providers = config.get_providers()
|
|
51
|
+
|
|
52
|
+
if not providers:
|
|
53
|
+
click.echo("No providers configured")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
click.echo(f"\nSynchronizing {len(providers)} provider(s)...")
|
|
57
|
+
|
|
58
|
+
# Initialize fetcher
|
|
59
|
+
fetcher = ProviderFetcher(cdn_url, config.library_dir)
|
|
60
|
+
|
|
61
|
+
# Track success/failure
|
|
62
|
+
installed = []
|
|
63
|
+
failed = []
|
|
64
|
+
|
|
65
|
+
# Process each provider
|
|
66
|
+
for provider_name, version in providers.items():
|
|
67
|
+
try:
|
|
68
|
+
# Check if provider needs installation/reload
|
|
69
|
+
needs_install = reload or not config.is_provider_installed(provider_name)
|
|
70
|
+
|
|
71
|
+
if needs_install:
|
|
72
|
+
if reload and config.is_provider_installed(provider_name):
|
|
73
|
+
click.echo(f" Removing {provider_name} for reload...")
|
|
74
|
+
config.remove_provider_installation(provider_name)
|
|
75
|
+
|
|
76
|
+
click.echo(f" Fetching {provider_name} @ {version}...")
|
|
77
|
+
fetcher.install_provider(
|
|
78
|
+
framework=current_framework,
|
|
79
|
+
provider_name=provider_name,
|
|
80
|
+
version=version,
|
|
81
|
+
force=reload
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Verify installation
|
|
85
|
+
if fetcher.verify_installation(provider_name):
|
|
86
|
+
installed.append(provider_name)
|
|
87
|
+
click.echo(f" ✓ Installed {provider_name}")
|
|
88
|
+
else:
|
|
89
|
+
failed.append(provider_name)
|
|
90
|
+
click.echo(f" ✗ Installation verification failed for {provider_name}", err=True)
|
|
91
|
+
else:
|
|
92
|
+
click.echo(f" ✓ {provider_name} already installed")
|
|
93
|
+
installed.append(provider_name)
|
|
94
|
+
|
|
95
|
+
except Exception as e:
|
|
96
|
+
failed.append(provider_name)
|
|
97
|
+
click.echo(f" ✗ Failed to install {provider_name}: {e}", err=True)
|
|
98
|
+
|
|
99
|
+
# Remove obsolete providers (installed but not in config)
|
|
100
|
+
installed_providers = config.get_installed_providers()
|
|
101
|
+
configured_providers = set(providers.keys())
|
|
102
|
+
obsolete = installed_providers - configured_providers
|
|
103
|
+
|
|
104
|
+
if obsolete:
|
|
105
|
+
click.echo(f"\nRemoving {len(obsolete)} obsolete provider(s)...")
|
|
106
|
+
for provider_name in obsolete:
|
|
107
|
+
try:
|
|
108
|
+
config.remove_provider_installation(provider_name)
|
|
109
|
+
click.echo(f" ✓ Removed {provider_name}")
|
|
110
|
+
except Exception as e:
|
|
111
|
+
click.echo(f" ✗ Failed to remove {provider_name}: {e}", err=True)
|
|
112
|
+
|
|
113
|
+
# Summary
|
|
114
|
+
click.echo(f"\n{'=' * 50}")
|
|
115
|
+
click.echo(f"Framework: {current_framework}")
|
|
116
|
+
click.echo(f"Successfully installed: {len(installed)}")
|
|
117
|
+
if failed:
|
|
118
|
+
click.echo(f"Failed: {len(failed)}")
|
|
119
|
+
if obsolete:
|
|
120
|
+
click.echo(f"Removed obsolete: {len(obsolete)}")
|
|
121
|
+
click.echo(f"{'=' * 50}")
|
commands/set.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Set command implementation
|
|
3
|
+
|
|
4
|
+
Sets the framework in PayKit configuration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from paykit.core.config import Config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.command()
|
|
13
|
+
@click.argument("framework")
|
|
14
|
+
def set_command(framework: str):
|
|
15
|
+
"""
|
|
16
|
+
Set the framework in PayKit configuration
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
framework: Framework name to set (e.g., django, flask)
|
|
20
|
+
"""
|
|
21
|
+
config = Config()
|
|
22
|
+
|
|
23
|
+
# Check if config exists
|
|
24
|
+
if not config.config_exists():
|
|
25
|
+
click.echo("Error: paykit.json not found. Run 'paykit init' first.", err=True)
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
# Load existing config
|
|
30
|
+
config.load_config()
|
|
31
|
+
|
|
32
|
+
# Get current framework
|
|
33
|
+
old_framework = config.get_framework()
|
|
34
|
+
|
|
35
|
+
# Set new framework
|
|
36
|
+
config.set_framework(framework)
|
|
37
|
+
|
|
38
|
+
if old_framework == framework:
|
|
39
|
+
click.echo(f"Framework is already set to: {framework}")
|
|
40
|
+
else:
|
|
41
|
+
click.echo(f"✓ Framework changed: {old_framework} → {framework}")
|
|
42
|
+
click.echo("\nNote: You may need to run 'paykit init --reload' to update providers for the new framework.")
|
|
43
|
+
|
|
44
|
+
except FileNotFoundError:
|
|
45
|
+
click.echo("Error: paykit.json not found. Run 'paykit init' first.", err=True)
|
|
46
|
+
except Exception as e:
|
|
47
|
+
click.echo(f"Error setting framework: {e}", err=True)
|
core/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core functionality
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from paykit.core.config import Config, config
|
|
6
|
+
from paykit.core.fetcher import ProviderFetcher
|
|
7
|
+
|
|
8
|
+
# from paykit.core.loader import ProviderLoader
|
|
9
|
+
|
|
10
|
+
# __all__ = ["Config", "ProviderFetcher", "ProviderLoader"]
|
|
11
|
+
__all__ = ["Config", "ProviderFetcher"]
|
core/config.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core configuration management for PayKit.
|
|
3
|
+
|
|
4
|
+
Changes over original:
|
|
5
|
+
- Atomic config saves (write temp → rename, no corrupt paykit.json on crash)
|
|
6
|
+
- get_cdn_url() normalises scheme so fetcher always gets a valid URL
|
|
7
|
+
- Multi-merchant: providers block supports per-provider config dict
|
|
8
|
+
{ "payme": { "version": "latest", "config": { "PAYME_KEY": "..." } } }
|
|
9
|
+
as well as the original flat string form { "payme": "latest" }
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, Optional, Set
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Config:
|
|
20
|
+
"""Manages PayKit configuration and provider state."""
|
|
21
|
+
|
|
22
|
+
CONFIG_FILENAME = "paykit.json"
|
|
23
|
+
DEFAULT_CDN_URL = "http://cdn.paykit.qzz.io"
|
|
24
|
+
|
|
25
|
+
def __init__(self, project_dir: Optional[Path] = None):
|
|
26
|
+
self.project_dir = project_dir or Path.cwd()
|
|
27
|
+
self.config_path = self.project_dir / self.CONFIG_FILENAME
|
|
28
|
+
self.library_dir = self._get_library_dir()
|
|
29
|
+
self.providers_dir = self.library_dir / "providers"
|
|
30
|
+
self._config_data: Optional[Dict] = None
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def _get_library_dir() -> Path:
|
|
34
|
+
import paykit
|
|
35
|
+
|
|
36
|
+
return Path(paykit.__file__).parent
|
|
37
|
+
|
|
38
|
+
# ── Load / save ──────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
def load_config(self) -> Dict:
|
|
41
|
+
if not self.config_path.exists():
|
|
42
|
+
raise FileNotFoundError(f"paykit.json not found: {self.config_path}")
|
|
43
|
+
with open(self.config_path, "r", encoding="utf-8") as fh:
|
|
44
|
+
self._config_data = json.load(fh)
|
|
45
|
+
return self._config_data
|
|
46
|
+
|
|
47
|
+
def save_config(self, config: Optional[Dict] = None) -> None:
|
|
48
|
+
"""Atomic save: write to .tmp then rename — crash-safe."""
|
|
49
|
+
data = config or self._config_data
|
|
50
|
+
if data is None:
|
|
51
|
+
raise ValueError("No configuration data to save")
|
|
52
|
+
tmp = self.config_path.with_suffix(".tmp")
|
|
53
|
+
with open(tmp, "w", encoding="utf-8") as fh:
|
|
54
|
+
json.dump(data, fh, indent=2, ensure_ascii=False)
|
|
55
|
+
# os.replace is atomic on POSIX; near-atomic on Windows
|
|
56
|
+
os.replace(tmp, self.config_path)
|
|
57
|
+
self._config_data = data
|
|
58
|
+
|
|
59
|
+
def initialize(
|
|
60
|
+
self, framework: str = "django", cdn_url: Optional[str] = None
|
|
61
|
+
) -> Dict:
|
|
62
|
+
config = {
|
|
63
|
+
"framework": framework,
|
|
64
|
+
"providers": {},
|
|
65
|
+
}
|
|
66
|
+
self.save_config(config)
|
|
67
|
+
return config
|
|
68
|
+
|
|
69
|
+
# ── Getters ──────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
def _data(self) -> Dict:
|
|
72
|
+
return self._config_data or self.load_config()
|
|
73
|
+
|
|
74
|
+
def get_framework(self) -> str:
|
|
75
|
+
return self._data().get("framework", "django")
|
|
76
|
+
|
|
77
|
+
def set_framework(self, framework: str) -> None:
|
|
78
|
+
cfg = self._data()
|
|
79
|
+
cfg["framework"] = framework
|
|
80
|
+
self.save_config(cfg)
|
|
81
|
+
|
|
82
|
+
def get_provider_defaults(self, provider_name: str) -> Dict[str, Any]:
|
|
83
|
+
return self._data().get("defaults", {}).get(provider_name, {})
|
|
84
|
+
|
|
85
|
+
def get_cdn_url(self) -> str:
|
|
86
|
+
url = self._data().get("cdn_url", self.DEFAULT_CDN_URL).rstrip("/")
|
|
87
|
+
if not url.startswith(("http://", "https://")):
|
|
88
|
+
url = "https://" + url
|
|
89
|
+
return url
|
|
90
|
+
|
|
91
|
+
# ── Provider config (multi-merchant aware) ───────────────────────────────
|
|
92
|
+
|
|
93
|
+
def get_providers(self) -> Dict[str, str]:
|
|
94
|
+
"""
|
|
95
|
+
Returns {provider_name: version} — same shape as before.
|
|
96
|
+
Works whether the stored value is a plain version string or a dict.
|
|
97
|
+
"""
|
|
98
|
+
raw: Dict[str, Any] = self._data().get("providers", {})
|
|
99
|
+
out: Dict[str, str] = {}
|
|
100
|
+
for name, val in raw.items():
|
|
101
|
+
if isinstance(val, dict):
|
|
102
|
+
out[name] = val.get("version", "latest")
|
|
103
|
+
else:
|
|
104
|
+
out[name] = str(val)
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
def get_provider_config(self, provider_name: str) -> Dict[str, Any]:
|
|
108
|
+
"""
|
|
109
|
+
Returns per-provider runtime config dict if present, else {}.
|
|
110
|
+
This is where multi-merchant keys live, e.g.:
|
|
111
|
+
{ "PAYME_KEY": "...", "CLICK_SECRET_KEY": "..." }
|
|
112
|
+
"""
|
|
113
|
+
raw: Dict[str, Any] = self._data().get("providers", {})
|
|
114
|
+
val = raw.get(provider_name, {})
|
|
115
|
+
if isinstance(val, dict):
|
|
116
|
+
return val.get("config", {})
|
|
117
|
+
return {}
|
|
118
|
+
|
|
119
|
+
def add_provider(
|
|
120
|
+
self, provider_name: str, version: str, config: Optional[Dict] = None
|
|
121
|
+
) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Add or update a provider.
|
|
124
|
+
If config is given the entry is stored as a dict, otherwise as a plain version string.
|
|
125
|
+
"""
|
|
126
|
+
cfg = self._data()
|
|
127
|
+
cfg.setdefault("providers", {})
|
|
128
|
+
if config:
|
|
129
|
+
existing = cfg["providers"].get(provider_name)
|
|
130
|
+
if isinstance(existing, dict):
|
|
131
|
+
existing["version"] = version
|
|
132
|
+
existing["config"] = config
|
|
133
|
+
else:
|
|
134
|
+
cfg["providers"][provider_name] = {"version": version, "config": config}
|
|
135
|
+
else:
|
|
136
|
+
cfg["providers"][provider_name] = version
|
|
137
|
+
self.save_config(cfg)
|
|
138
|
+
|
|
139
|
+
def remove_provider(self, provider_name: str) -> None:
|
|
140
|
+
cfg = self._data()
|
|
141
|
+
cfg.get("providers", {}).pop(provider_name, None)
|
|
142
|
+
self.save_config(cfg)
|
|
143
|
+
|
|
144
|
+
# ── Installation state ───────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
def get_installed_providers(self) -> Set[str]:
|
|
147
|
+
if not self.providers_dir.exists():
|
|
148
|
+
return set()
|
|
149
|
+
return {
|
|
150
|
+
p.name
|
|
151
|
+
for p in self.providers_dir.iterdir()
|
|
152
|
+
if p.is_dir() and not p.name.startswith("_")
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
def is_provider_installed(self, provider_name: str) -> bool:
|
|
156
|
+
return (self.providers_dir / provider_name).is_dir()
|
|
157
|
+
|
|
158
|
+
def remove_provider_installation(self, provider_name: str) -> None:
|
|
159
|
+
path = self.providers_dir / provider_name
|
|
160
|
+
if path.exists():
|
|
161
|
+
shutil.rmtree(path)
|
|
162
|
+
|
|
163
|
+
# ── Misc ─────────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
def get_python_version(self) -> str:
|
|
166
|
+
import sys
|
|
167
|
+
|
|
168
|
+
return f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
169
|
+
|
|
170
|
+
def validate_config(self) -> bool:
|
|
171
|
+
cfg = self._data()
|
|
172
|
+
for field in ("cdn_url", "framework", "providers"):
|
|
173
|
+
if field not in cfg:
|
|
174
|
+
raise ValueError(f"Missing required field: {field}")
|
|
175
|
+
if not isinstance(cfg["providers"], dict):
|
|
176
|
+
raise ValueError("'providers' must be a dict")
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
def config_exists(self) -> bool:
|
|
180
|
+
return self.config_path.exists()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
config = Config()
|
core/fetcher.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Provider fetcher — downloads and installs providers from CDN.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import tarfile
|
|
9
|
+
import time
|
|
10
|
+
import zipfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Dict, List
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProviderFetcher:
|
|
18
|
+
def __init__(self, cdn_url: str, library_dir: Path):
|
|
19
|
+
raw = cdn_url.rstrip("/")
|
|
20
|
+
if not raw.startswith(("http://", "https://")):
|
|
21
|
+
raw = "https://" + raw
|
|
22
|
+
self.cdn_url = raw
|
|
23
|
+
self.library_dir = library_dir
|
|
24
|
+
self.providers_dir = library_dir / "providers"
|
|
25
|
+
self.providers_dir.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
|
|
27
|
+
# ── CDN discovery ─────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
def fetch_available_providers(self, framework: str) -> List[str]:
|
|
30
|
+
"""GET /providers/{framework}/available → list of provider names."""
|
|
31
|
+
url = f"{self.cdn_url}/providers/{framework}/available"
|
|
32
|
+
resp = requests.get(url, timeout=15)
|
|
33
|
+
resp.raise_for_status()
|
|
34
|
+
data = resp.json()
|
|
35
|
+
if not isinstance(data, list):
|
|
36
|
+
raise ValueError(f"Expected list from {url}, got {type(data)}")
|
|
37
|
+
return data
|
|
38
|
+
|
|
39
|
+
def fetch_available_versions(self, framework: str, provider_name: str) -> List[str]:
|
|
40
|
+
"""GET /providers/{framework}/{provider}/available → list of versions."""
|
|
41
|
+
url = f"{self.cdn_url}/providers/{framework}/{provider_name}/available"
|
|
42
|
+
resp = requests.get(url, timeout=15)
|
|
43
|
+
resp.raise_for_status()
|
|
44
|
+
data = resp.json()
|
|
45
|
+
if not isinstance(data, list):
|
|
46
|
+
raise ValueError(f"Expected list from {url}, got {type(data)}")
|
|
47
|
+
return data
|
|
48
|
+
|
|
49
|
+
def fetch_metadata(self, framework: str, provider_name: str, version: str) -> Dict:
|
|
50
|
+
"""GET /providers/{framework}/{provider}/{version} → metadata dict with download_url."""
|
|
51
|
+
url = f"{self.cdn_url}/providers/{framework}/{provider_name}/{version}"
|
|
52
|
+
resp = requests.get(url, timeout=15)
|
|
53
|
+
resp.raise_for_status()
|
|
54
|
+
return resp.json()
|
|
55
|
+
|
|
56
|
+
# ── Install ───────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
def install_provider(
|
|
59
|
+
self,
|
|
60
|
+
framework: str,
|
|
61
|
+
provider_name: str,
|
|
62
|
+
version: str,
|
|
63
|
+
force: bool = False,
|
|
64
|
+
) -> Path:
|
|
65
|
+
provider_dir = self.providers_dir / provider_name
|
|
66
|
+
|
|
67
|
+
if provider_dir.exists() and not force:
|
|
68
|
+
return provider_dir
|
|
69
|
+
|
|
70
|
+
metadata = self.fetch_metadata(framework, provider_name, version)
|
|
71
|
+
download_url = metadata.get("download_url", "").strip()
|
|
72
|
+
if not download_url:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"CDN returned no download_url for {provider_name}@{version}"
|
|
75
|
+
)
|
|
76
|
+
if not download_url.startswith(("http://", "https://")):
|
|
77
|
+
download_url = "https://" + download_url
|
|
78
|
+
|
|
79
|
+
resp = requests.get(download_url, timeout=60, stream=True)
|
|
80
|
+
resp.raise_for_status()
|
|
81
|
+
|
|
82
|
+
# Unique temp paths — safe against concurrent paykit runs
|
|
83
|
+
uid = f"{os.getpid()}_{int(time.time() * 1000)}"
|
|
84
|
+
is_tar = ".tar" in download_url or download_url.endswith(".tgz")
|
|
85
|
+
suffix = ".tar.gz" if is_tar else ".zip"
|
|
86
|
+
tmp_archive = self.providers_dir / f"_{provider_name}_{uid}{suffix}"
|
|
87
|
+
tmp_dir = self.providers_dir / f"_{provider_name}_{uid}_extract"
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
with open(tmp_archive, "wb") as fh:
|
|
91
|
+
for chunk in resp.iter_content(chunk_size=8192):
|
|
92
|
+
fh.write(chunk)
|
|
93
|
+
|
|
94
|
+
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
|
|
96
|
+
if is_tar:
|
|
97
|
+
with tarfile.open(tmp_archive, "r:gz") as tf:
|
|
98
|
+
tf.extractall(tmp_dir)
|
|
99
|
+
else:
|
|
100
|
+
with zipfile.ZipFile(tmp_archive, "r") as zf:
|
|
101
|
+
zf.extractall(tmp_dir)
|
|
102
|
+
|
|
103
|
+
# The archive may wrap everything in a single top-level dir — unwrap it
|
|
104
|
+
extracted = list(tmp_dir.iterdir())
|
|
105
|
+
if len(extracted) == 1 and extracted[0].is_dir():
|
|
106
|
+
actual_dir = extracted[0]
|
|
107
|
+
else:
|
|
108
|
+
actual_dir = tmp_dir
|
|
109
|
+
|
|
110
|
+
if provider_dir.exists():
|
|
111
|
+
shutil.rmtree(provider_dir)
|
|
112
|
+
actual_dir.rename(provider_dir)
|
|
113
|
+
|
|
114
|
+
except Exception:
|
|
115
|
+
if tmp_dir.exists():
|
|
116
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
117
|
+
raise
|
|
118
|
+
finally:
|
|
119
|
+
if tmp_archive.exists():
|
|
120
|
+
tmp_archive.unlink()
|
|
121
|
+
if tmp_dir.exists():
|
|
122
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
123
|
+
|
|
124
|
+
return provider_dir
|
|
125
|
+
|
|
126
|
+
def verify_installation(self, provider_name: str) -> bool:
|
|
127
|
+
provider_dir = self.providers_dir / provider_name
|
|
128
|
+
if not provider_dir.is_dir():
|
|
129
|
+
return False
|
|
130
|
+
if not (provider_dir / "__init__.py").exists():
|
|
131
|
+
return False
|
|
132
|
+
manifest = provider_dir / "manifest.json"
|
|
133
|
+
if not manifest.exists():
|
|
134
|
+
return False
|
|
135
|
+
try:
|
|
136
|
+
with open(manifest) as fh:
|
|
137
|
+
json.load(fh)
|
|
138
|
+
except (json.JSONDecodeError, OSError):
|
|
139
|
+
return False
|
|
140
|
+
return True
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: paykit-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Payment provider integration toolkit
|
|
5
|
+
Home-page: https://github.com/abrorbekuz/paykit
|
|
6
|
+
Author: Abror Qodirov
|
|
7
|
+
Author-email: splayerme@gmail.com
|
|
8
|
+
Requires-Python: >=3.7
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: click>=8.0.0
|
|
11
|
+
Requires-Dist: requests>=2.25.0
|
|
12
|
+
Dynamic: author
|
|
13
|
+
Dynamic: author-email
|
|
14
|
+
Dynamic: description
|
|
15
|
+
Dynamic: description-content-type
|
|
16
|
+
Dynamic: home-page
|
|
17
|
+
Dynamic: requires-dist
|
|
18
|
+
Dynamic: requires-python
|
|
19
|
+
Dynamic: summary
|
|
20
|
+
|
|
21
|
+
# PayKit
|
|
22
|
+
|
|
23
|
+
Payment provider integration toolkit for Django and other web frameworks.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install paykit-sdk
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quick Start
|
|
32
|
+
|
|
33
|
+
Initialize PayKit in your project directory. If `paykit.json` already exists, it will sync automatically.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
paykit init
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## CLI Reference
|
|
40
|
+
|
|
41
|
+
### Set Framework
|
|
42
|
+
|
|
43
|
+
PayKit auto-detects your framework. Only run this if detection fails or you want to override it.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
paykit set django
|
|
47
|
+
paykit set flask
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Add a Provider
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
paykit add payme # latest version
|
|
54
|
+
paykit add payme@latest # explicitly latest
|
|
55
|
+
paykit add payme@1 # major version
|
|
56
|
+
paykit add payme@1.0.0 # exact version
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Re-sync Config
|
|
60
|
+
|
|
61
|
+
After editing `paykit.json`, re-run init to apply changes:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
paykit init
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
`paykit.json` is auto-generated on `paykit init`. Edit it as needed, then re-run `paykit init`.
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"framework": "django",
|
|
74
|
+
"providers": {
|
|
75
|
+
"payme": "latest"
|
|
76
|
+
},
|
|
77
|
+
"defaults": {
|
|
78
|
+
"payme": {
|
|
79
|
+
"language": "uz",
|
|
80
|
+
"request_link": "https://test.paycom.uz",
|
|
81
|
+
"callback_link": "https://your-domain.com/payme_endpoint/"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
| Field | Description | Options |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `language` | Payment page language shown to user | `uz`, `ru`, `en` |
|
|
90
|
+
| `request_link` | Payme checkout URL | Use test URL during development, production URL when live |
|
|
91
|
+
| `callback_link` | Redirect URL after payment completes | Must be a publicly accessible URL |
|
|
92
|
+
|
|
93
|
+
## Supported
|
|
94
|
+
|
|
95
|
+
| Category | Supported |
|
|
96
|
+
|---|---|
|
|
97
|
+
| Languages | Python |
|
|
98
|
+
| Frameworks | Django |
|
|
99
|
+
| Providers | Payme |
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
For Django-specific integration (views, webhook handlers, payment link generation), see [Usage](/paykit/usage).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
commands/__init__.py,sha256=F2fUYkoQRW_hPmh3sp-yVw-DoLDJp0gEv0YkUF6_Nbw,218
|
|
2
|
+
commands/add.py,sha256=bBpZOjQm-hx9cU-OI7uHpPIJOVh_e48IilIFruJC1YQ,5075
|
|
3
|
+
commands/init.py,sha256=p7gaByUAB94K-k_XNLevnpZjbEQ53wGUHXwiCy8qwTU,4040
|
|
4
|
+
commands/set.py,sha256=FH-2fKcAWyjCXggl8fnXxe5eeqPwBw03EF590osJRzA,1289
|
|
5
|
+
core/__init__.py,sha256=GbGHxjfkRCVx-UNLkvS3D-ZVbNvA0zfjd1oHu7bq3Rg,272
|
|
6
|
+
core/config.py,sha256=_YS7Dno46zldYnL4NDO8ViGSbGLrQq18lCcaJD0nqYE,7038
|
|
7
|
+
core/fetcher.py,sha256=p-Bo8j4C6byd_Iu50SDdknqzPY8XCpx3TIWqzgi9Ahc,5338
|
|
8
|
+
paykit_sdk-0.1.0.dist-info/METADATA,sha256=n9SYif45VRPV1sx6mQJFsNYf4YHlXLdGUVBEYAzRGjg,2232
|
|
9
|
+
paykit_sdk-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
paykit_sdk-0.1.0.dist-info/entry_points.txt,sha256=2JjKUfW2ImxDua108on5hg31apZMeTUKw7i2GNMZsOU,42
|
|
11
|
+
paykit_sdk-0.1.0.dist-info/top_level.txt,sha256=Z_mfyM_t0Id8-bdJwN5KANmvkNmNot6ubFnG7U5Cbpo,14
|
|
12
|
+
paykit_sdk-0.1.0.dist-info/RECORD,,
|