paykit-sdk 0.1.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.
@@ -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,83 @@
1
+ # PayKit
2
+
3
+ Payment provider integration toolkit for Django and other web frameworks.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install paykit-sdk
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ Initialize PayKit in your project directory. If `paykit.json` already exists, it will sync automatically.
14
+
15
+ ```bash
16
+ paykit init
17
+ ```
18
+
19
+ ## CLI Reference
20
+
21
+ ### Set Framework
22
+
23
+ PayKit auto-detects your framework. Only run this if detection fails or you want to override it.
24
+
25
+ ```bash
26
+ paykit set django
27
+ paykit set flask
28
+ ```
29
+
30
+ ### Add a Provider
31
+
32
+ ```bash
33
+ paykit add payme # latest version
34
+ paykit add payme@latest # explicitly latest
35
+ paykit add payme@1 # major version
36
+ paykit add payme@1.0.0 # exact version
37
+ ```
38
+
39
+ ### Re-sync Config
40
+
41
+ After editing `paykit.json`, re-run init to apply changes:
42
+
43
+ ```bash
44
+ paykit init
45
+ ```
46
+
47
+ ## Configuration
48
+
49
+ `paykit.json` is auto-generated on `paykit init`. Edit it as needed, then re-run `paykit init`.
50
+
51
+ ```json
52
+ {
53
+ "framework": "django",
54
+ "providers": {
55
+ "payme": "latest"
56
+ },
57
+ "defaults": {
58
+ "payme": {
59
+ "language": "uz",
60
+ "request_link": "https://test.paycom.uz",
61
+ "callback_link": "https://your-domain.com/payme_endpoint/"
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ | Field | Description | Options |
68
+ |---|---|---|
69
+ | `language` | Payment page language shown to user | `uz`, `ru`, `en` |
70
+ | `request_link` | Payme checkout URL | Use test URL during development, production URL when live |
71
+ | `callback_link` | Redirect URL after payment completes | Must be a publicly accessible URL |
72
+
73
+ ## Supported
74
+
75
+ | Category | Supported |
76
+ |---|---|
77
+ | Languages | Python |
78
+ | Frameworks | Django |
79
+ | Providers | Payme |
80
+
81
+ ---
82
+
83
+ For Django-specific integration (views, webhook handlers, payment link generation), see [Usage](/paykit/usage).
@@ -0,0 +1,9 @@
1
+ """
2
+ Commands package
3
+ """
4
+
5
+ from paykit.commands.init import init_command
6
+ from paykit.commands.set import set_command
7
+ from paykit.commands.add import add_command
8
+
9
+ __all__ = ["init_command", "set_command", "add_command"]
@@ -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)
@@ -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}")
@@ -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)
@@ -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"]
@@ -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()
@@ -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,15 @@
1
+ README.md
2
+ setup.py
3
+ commands/__init__.py
4
+ commands/add.py
5
+ commands/init.py
6
+ commands/set.py
7
+ core/__init__.py
8
+ core/config.py
9
+ core/fetcher.py
10
+ paykit_sdk.egg-info/PKG-INFO
11
+ paykit_sdk.egg-info/SOURCES.txt
12
+ paykit_sdk.egg-info/dependency_links.txt
13
+ paykit_sdk.egg-info/entry_points.txt
14
+ paykit_sdk.egg-info/requires.txt
15
+ paykit_sdk.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ paykit = paykit.cli:cli
@@ -0,0 +1,2 @@
1
+ click>=8.0.0
2
+ requests>=2.25.0
@@ -0,0 +1,2 @@
1
+ commands
2
+ core
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,27 @@
1
+ from setuptools import find_packages, setup
2
+
3
+ with open("README.md", "r", encoding="utf-8") as fh:
4
+ long_description = fh.read()
5
+
6
+ setup(
7
+ name="paykit-sdk",
8
+ version="0.1.0",
9
+ author="Abror Qodirov",
10
+ author_email="splayerme@gmail.com",
11
+ description="Payment provider integration toolkit",
12
+ long_description=long_description,
13
+ long_description_content_type="text/markdown",
14
+ url="https://github.com/abrorbekuz/paykit",
15
+ packages=find_packages(where="."),
16
+ package_dir={"paykit": "."},
17
+ python_requires=">=3.7",
18
+ install_requires=[
19
+ "click>=8.0.0",
20
+ "requests>=2.25.0",
21
+ ],
22
+ entry_points={
23
+ "console_scripts": [
24
+ "paykit=paykit.cli:cli",
25
+ ],
26
+ },
27
+ )