hrd-py 0.0.1__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.
hrd_py-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: hrd-py
3
+ Version: 0.0.1
4
+ Summary: A Python library and CLI for the HRD.pl API
5
+ Author: TheUndefined
6
+ License: MIT
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: click>=8.0.0
9
+ Requires-Dist: python-dotenv>=0.19.0
10
+ Requires-Dist: PyYAML>=6.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
13
+ Requires-Dist: pytest-mock>=3.14.0; extra == "dev"
14
+ Requires-Dist: ruff>=0.3.0; extra == "dev"
15
+ Requires-Dist: black>=24.0.0; extra == "dev"
16
+ Requires-Dist: mypy>=1.9.0; extra == "dev"
17
+ Requires-Dist: types-PyYAML>=6.0.0; extra == "dev"
18
+ Requires-Dist: build>=1.0.0; extra == "dev"
19
+ Requires-Dist: twine>=5.0.0; extra == "dev"
20
+
21
+ # hrd-py
22
+
23
+ A Python library and CLI for the HRD.pl API.
24
+
25
+ ## Features
26
+ - Check account balance.
27
+ - List domains and their status.
28
+ - Monitor domain expiry.
29
+ - Renew domains manually or automatically.
30
+
31
+ ## Installation
32
+ ```bash
33
+ pip install .
34
+ ```
35
+
36
+ ## Configuration
37
+ Create a `.env` file with your HRD.pl credentials:
38
+ ```env
39
+ HRD_LOGIN=your_login
40
+ HRD_PASS=your_api_password
41
+ HRD_HASH=your_api_hash
42
+ ```
43
+
44
+ ## Usage
45
+ ### CLI
46
+ ```bash
47
+ hrd balance
48
+ hrd domains --all
49
+ hrd expiring --days 30
50
+ hrd renew example.com
51
+ hrd auto-renew --days 7
52
+ ```
53
+
54
+ ### Library
55
+ ```python
56
+ from hrd_py import HRDClient
57
+
58
+ client = HRDClient(login, password, api_hash)
59
+ client.login()
60
+ balance = client.get_balance()
61
+ print(f"Balance: {balance.balance}")
62
+ ```
hrd_py-0.0.1/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # hrd-py
2
+
3
+ A Python library and CLI for the HRD.pl API.
4
+
5
+ ## Features
6
+ - Check account balance.
7
+ - List domains and their status.
8
+ - Monitor domain expiry.
9
+ - Renew domains manually or automatically.
10
+
11
+ ## Installation
12
+ ```bash
13
+ pip install .
14
+ ```
15
+
16
+ ## Configuration
17
+ Create a `.env` file with your HRD.pl credentials:
18
+ ```env
19
+ HRD_LOGIN=your_login
20
+ HRD_PASS=your_api_password
21
+ HRD_HASH=your_api_hash
22
+ ```
23
+
24
+ ## Usage
25
+ ### CLI
26
+ ```bash
27
+ hrd balance
28
+ hrd domains --all
29
+ hrd expiring --days 30
30
+ hrd renew example.com
31
+ hrd auto-renew --days 7
32
+ ```
33
+
34
+ ### Library
35
+ ```python
36
+ from hrd_py import HRDClient
37
+
38
+ client = HRDClient(login, password, api_hash)
39
+ client.login()
40
+ balance = client.get_balance()
41
+ print(f"Balance: {balance.balance}")
42
+ ```
File without changes
@@ -0,0 +1,208 @@
1
+ import socket
2
+ import ssl
3
+ import hashlib
4
+ import struct
5
+ import xml.etree.ElementTree as ET
6
+ from typing import Optional, List, Dict, Any
7
+ from .exceptions import HRDCommunicationError, HRDAPIError, HRDAuthError
8
+
9
+
10
+ class HRDApi:
11
+ def __init__(
12
+ self,
13
+ api_login: str,
14
+ api_pass: str,
15
+ api_hash: str,
16
+ host: str = "api.hrd.pl",
17
+ port: int = 9999,
18
+ timeout: int = 10,
19
+ verify_peer: bool = True,
20
+ ):
21
+ self.api_login = api_login
22
+ self.api_pass = api_pass
23
+ self.api_hash = bytes.fromhex(api_hash)
24
+ self.host = host
25
+ self.port = port
26
+ self.timeout = timeout
27
+ self.verify_peer = verify_peer
28
+
29
+ self.token: Optional[str] = None
30
+ self._sock: Optional[socket.socket] = None
31
+ self._ssl_sock: Optional[ssl.SSLSocket] = None
32
+
33
+ def _connect(self):
34
+ if self._ssl_sock:
35
+ return
36
+
37
+ try:
38
+ self._sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
39
+ context = ssl.create_default_context()
40
+ if not self.verify_peer:
41
+ context.check_hostname = False
42
+ context.verify_mode = ssl.CERT_NONE
43
+
44
+ self._ssl_sock = context.wrap_socket(self._sock, server_hostname=self.host)
45
+ except Exception as e:
46
+ raise HRDCommunicationError(f"Failed to connect to {self.host}:{self.port}: {e}")
47
+
48
+ def _disconnect(self):
49
+ if self._ssl_sock:
50
+ self._ssl_sock.close()
51
+ self._ssl_sock = None
52
+ if self._sock:
53
+ self._sock.close()
54
+ self._sock = None
55
+
56
+ def _send(self, xml_data: str):
57
+ self._connect()
58
+ assert self._ssl_sock is not None
59
+
60
+ # Calculate hash: SHA512(XML + binary_api_hash)
61
+ # Note: XML must be the exact string, including any whitespace or lack thereof
62
+ payload_bytes = xml_data.encode("utf-8")
63
+ h = hashlib.sha512()
64
+ h.update(payload_bytes)
65
+ h.update(self.api_hash)
66
+ hash_bytes = h.digest()
67
+
68
+ full_payload = hash_bytes + payload_bytes
69
+ length_prefix = struct.pack(">I", len(full_payload))
70
+
71
+ try:
72
+ self._ssl_sock.sendall(length_prefix + full_payload)
73
+ except Exception as e:
74
+ self._disconnect()
75
+ raise HRDCommunicationError(f"Failed to send data: {e}")
76
+
77
+ def _read(self) -> str:
78
+ assert self._ssl_sock is not None
79
+ try:
80
+ length_data = self._ssl_sock.recv(4)
81
+ if len(length_data) < 4:
82
+ raise HRDCommunicationError("Failed to read response length")
83
+
84
+ length = struct.unpack(">I", length_data)[0]
85
+ response_data = b""
86
+ while len(response_data) < length:
87
+ chunk = self._ssl_sock.recv(length - len(response_data))
88
+ if not chunk:
89
+ break
90
+ response_data += chunk
91
+
92
+ if len(response_data) != length:
93
+ raise HRDCommunicationError(f"Expected {length} bytes, got {len(response_data)}")
94
+
95
+ return response_data.decode("utf-8")
96
+ except Exception as e:
97
+ self._disconnect()
98
+ raise HRDCommunicationError(f"Failed to read response: {e}")
99
+
100
+ def _request(self, module: str, method: str = "", params: Optional[Dict[str, Any]] = None) -> ET.Element:
101
+ # Construct XML - mirror PHP DOMDocument behavior
102
+ # PHP: $dom = new \DOMDocument('1.0', 'utf-8');
103
+ # PHP: $api = $dom->createElementNS('http://api.hrd.pl/api/', 'api');
104
+
105
+ # We'll use a manual XML string to be safe about formatting, or carefully use ET
106
+ root = ET.Element("api", xmlns="http://api.hrd.pl/api/")
107
+
108
+ # NOTE: Token is NOT included in normal requests in PHP library!
109
+ # It's only used in loginByToken method which sends <api><token>...</token></api>
110
+
111
+ module_elem = ET.SubElement(root, module)
112
+ if method:
113
+ target_elem = ET.SubElement(module_elem, method)
114
+ else:
115
+ target_elem = module_elem
116
+
117
+ if params:
118
+ self._dict_to_xml(target_elem, params)
119
+
120
+ # PHP's saveXML() usually includes the XML declaration and some whitespace
121
+ # but the schema validation might depend on it.
122
+ # Let's try without declaration first as we did before, but with encoding="utf-8"
123
+ xml_str = ET.tostring(root, encoding="unicode")
124
+
125
+ self._send(xml_str)
126
+
127
+ response_xml = self._read()
128
+ # The PHP code removes the xmlns before parsing
129
+ response_xml = response_xml.replace('xmlns="http://api.hrd.pl/api/"', "")
130
+
131
+ resp_root = ET.fromstring(response_xml)
132
+
133
+ # Check for error
134
+ error_msg = resp_root.find("message")
135
+ if error_msg is not None:
136
+ raise HRDAPIError(error_msg.text)
137
+
138
+ status = resp_root.find("status")
139
+ if status is not None and status.text == "error":
140
+ raise HRDAPIError("API returned error status")
141
+
142
+ return resp_root
143
+
144
+ def _dict_to_xml(self, parent, data: Dict[str, Any]):
145
+ for key, value in data.items():
146
+ child = ET.SubElement(parent, key)
147
+ if isinstance(value, dict):
148
+ self._dict_to_xml(child, value)
149
+ elif isinstance(value, list):
150
+ pass
151
+ else:
152
+ child.text = str(value)
153
+
154
+ def login(self, login_type: str = "partnerApi"):
155
+ params = {"login": self.api_login, "pass": self.api_pass, "type": login_type}
156
+ resp = self._request("login", "", params)
157
+
158
+ token = resp.find("token")
159
+ if token is not None:
160
+ self.token = token.text
161
+ return self.token
162
+ raise HRDAuthError("Login failed, no token received")
163
+
164
+ def partner_get_balance(self) -> Dict[str, float]:
165
+ resp = self._request("partner", "getBalance")
166
+ balance_elem = resp.find(".//getBalance")
167
+ if balance_elem is None:
168
+ balance_elem = resp.find(".//partner/getBalance")
169
+
170
+ if balance_elem is not None:
171
+ balance_child = balance_elem.find("balance")
172
+ restricted_child = balance_elem.find("restrictedBalance")
173
+ if balance_child is not None and balance_child.text is not None:
174
+ if restricted_child is not None and restricted_child.text is not None:
175
+ return {
176
+ "balance": float(balance_child.text),
177
+ "restricted_balance": float(restricted_child.text),
178
+ }
179
+ raise HRDAPIError("Could not find balance information in response")
180
+
181
+ def domain_list(self, last_name: Optional[str] = None) -> List[str]:
182
+ params = {}
183
+ if last_name:
184
+ params["lastName"] = last_name
185
+
186
+ resp = self._request("domain", "list", params)
187
+ names = resp.findall(".//domain/list/name")
188
+ return [n.text for n in names if n.text is not None]
189
+
190
+ def domain_info(self, domain_name: str) -> Dict[str, Any]:
191
+ params = {"name": domain_name}
192
+ resp = self._request("domain", "info", params)
193
+ info_elem = resp.find(".//domain/info")
194
+
195
+ if info_elem is not None:
196
+ info = {}
197
+ for child in info_elem:
198
+ info[child.tag] = child.text
199
+ return info
200
+ raise HRDAPIError(f"Could not find info for domain {domain_name}")
201
+
202
+ def domain_renew(self, domain_name: str, current_expiry_date: str, period: int = 1) -> int:
203
+ params = {"name": domain_name, "currentExpirationDate": current_expiry_date, "period": period}
204
+ resp = self._request("domain", "renew", params)
205
+ action_id = resp.find(".//domain/renew/actionId")
206
+ if action_id is not None and action_id.text is not None:
207
+ return int(action_id.text)
208
+ raise HRDAPIError(f"Renewal failed for domain {domain_name}")
@@ -0,0 +1,205 @@
1
+ import os
2
+ import sys
3
+ import click
4
+ from .client import HRDClient
5
+ from .exceptions import HRDError
6
+ from .config import ConfigManager
7
+ from dotenv import load_dotenv
8
+
9
+ # Still load dotenv for backward compatibility or explicit override
10
+ load_dotenv(override=True)
11
+
12
+
13
+ class CLIContext:
14
+ def __init__(self, profile=None):
15
+ self.config_manager = ConfigManager()
16
+ self.profile_name = profile or self.config_manager.get_default_profile_name()
17
+ self._client = None
18
+
19
+ def get_client(self):
20
+ if self._client:
21
+ return self._client
22
+
23
+ # 1. Try from config
24
+ profile = self.config_manager.get_profile(self.profile_name)
25
+ if profile:
26
+ self._client = HRDClient(profile["login"], profile["password"], profile["api_hash"])
27
+ return self._client
28
+
29
+ # 2. Fallback to ENV (only if no specific profile requested)
30
+ if not self.profile_name or self.profile_name == self.config_manager.get_default_profile_name():
31
+ login = os.getenv("HRD_LOGIN")
32
+ password = os.getenv("HRD_PASS")
33
+ api_hash = os.getenv("HRD_HASH")
34
+
35
+ if all([login, password, api_hash]):
36
+ self._client = HRDClient(login, password, api_hash)
37
+ return self._client
38
+
39
+ click.echo(f"Error: Missing configuration for profile '{self.profile_name or 'default'}'.")
40
+ click.echo("Use 'hrd profile add' to set up credentials.")
41
+ sys.exit(1)
42
+
43
+
44
+ @click.group()
45
+ @click.option("--profile", help="Use a specific profile from config")
46
+ @click.pass_context
47
+ def cli(ctx, profile):
48
+ """HRD.pl API Command Line Interface"""
49
+ ctx.obj = CLIContext(profile)
50
+
51
+
52
+ @cli.group()
53
+ def profile():
54
+ """Manage account profiles"""
55
+ pass
56
+
57
+
58
+ @profile.command(name="add")
59
+ @click.argument("name")
60
+ @click.option("--login", prompt=True, help="HRD API Login")
61
+ @click.option("--password", prompt=True, hide_input=True, help="HRD API Password")
62
+ @click.option("--hash", prompt=True, help="HRD API Hash")
63
+ @click.pass_obj
64
+ def profile_add(obj, name, login, password, hash):
65
+ """Add a new profile"""
66
+ obj.config_manager.add_profile(name, login, password, hash)
67
+ click.echo(f"Profile '{name}' added successfully.")
68
+
69
+
70
+ @profile.command(name="list")
71
+ @click.pass_obj
72
+ def profile_list(obj):
73
+ """List all configured profiles"""
74
+ profiles = obj.config_manager.list_profiles()
75
+ default = obj.config_manager.get_default_profile_name()
76
+ if not profiles:
77
+ click.echo("No profiles configured.")
78
+ return
79
+ for p in profiles:
80
+ star = "*" if p == default else " "
81
+ click.echo(f"{star} {p}")
82
+
83
+
84
+ @profile.command(name="set-default")
85
+ @click.argument("name")
86
+ @click.pass_obj
87
+ def profile_set_default(obj, name):
88
+ """Set a profile as default"""
89
+ if obj.config_manager.set_default(name):
90
+ click.echo(f"Default profile set to '{name}'.")
91
+ else:
92
+ click.echo(f"Error: Profile '{name}' not found.")
93
+
94
+
95
+ @cli.command()
96
+ @click.pass_obj
97
+ def balance(obj):
98
+ """Show account balance"""
99
+ client = obj.get_client()
100
+ try:
101
+ client.login()
102
+ b = client.get_balance()
103
+ click.echo(f"Profile: {obj.profile_name or 'default'}")
104
+ click.echo(f"Current Balance: {b.balance}")
105
+ click.echo(f"Restricted Balance: {b.restricted_balance}")
106
+ except HRDError as e:
107
+ click.echo(f"Error: {e}")
108
+
109
+
110
+ @cli.command()
111
+ @click.option("--all", is_flag=True, help="List all domains, not just expiring")
112
+ @click.option("--days", default=30, help="Days until expiry for filtering")
113
+ @click.pass_obj
114
+ def domains(obj, all, days):
115
+ """List domains and their status"""
116
+ client = obj.get_client()
117
+ try:
118
+ client.login()
119
+ click.echo(f"Fetching domains for {obj.profile_name or 'default'}...")
120
+ domains = client.list_domains()
121
+
122
+ for d in domains:
123
+ if all or d.is_expiring_soon(days):
124
+ expiry_str = d.expiry_date.strftime("%Y-%m-%d") if d.expiry_date else "Unknown"
125
+ status_str = click.style(d.status, fg="red" if d.status == "expired" else "green")
126
+ click.echo(f"{d.name:30} | {expiry_str:10} | {status_str}")
127
+ except HRDError as e:
128
+ click.echo(f"Error: {e}")
129
+
130
+
131
+ @cli.command()
132
+ @click.argument("domain")
133
+ @click.option("--period", default=1, help="Renewal period in years")
134
+ @click.pass_obj
135
+ def renew(obj, domain, period):
136
+ """Renew a specific domain"""
137
+ client = obj.get_client()
138
+ try:
139
+ client.login()
140
+ click.echo(f"Renewing domain {domain} for {period} year(s)...")
141
+ action_id = client.renew_domain(domain, period)
142
+ click.echo(f"Success! Action ID: {action_id}")
143
+ except HRDError as e:
144
+ click.echo(f"Error: {e}")
145
+
146
+
147
+ @cli.command()
148
+ @click.option("--days", default=30, help="Days until expiry for automatic renewal")
149
+ @click.option("--dry-run", is_flag=True, help="Don't actually perform renewal")
150
+ @click.option("--interactive", "-i", is_flag=True, help="Confirm each domain before renewal")
151
+ @click.option("--all-profiles", is_flag=True, help="Process all configured profiles")
152
+ @click.pass_obj
153
+ def auto_renew(obj, days, dry_run, interactive, all_profiles):
154
+ """Automatically renew expiring domains"""
155
+
156
+ profiles_to_process = []
157
+ if all_profiles:
158
+ profiles_to_process = obj.config_manager.list_profiles()
159
+ if not profiles_to_process:
160
+ # If no profiles in config, check if we have ENV vars as a pseudo-profile
161
+ if os.getenv("HRD_LOGIN"):
162
+ profiles_to_process = [None] # Use default/env logic
163
+ else:
164
+ profiles_to_process = [obj.profile_name]
165
+
166
+ for p_name in profiles_to_process:
167
+ # Create a fresh context for each profile if processing all
168
+ if all_profiles:
169
+ ctx_profile = CLIContext(p_name)
170
+ else:
171
+ ctx_profile = obj
172
+
173
+ click.echo(f"\n--- Processing profile: {p_name or 'default'} ---")
174
+ try:
175
+ client = ctx_profile.get_client()
176
+ client.login()
177
+ click.echo(f"Checking for domains expiring within {days} days...")
178
+ domains = client.list_domains()
179
+ expiring = [d for d in domains if d.is_expiring_soon(days)]
180
+
181
+ if not expiring:
182
+ click.echo("No domains found for renewal.")
183
+ continue
184
+
185
+ for d in expiring:
186
+ if dry_run:
187
+ click.echo(f"[DRY RUN] Would renew {d.name}")
188
+ else:
189
+ if interactive:
190
+ if not click.confirm(f"Renew {d.name}?", default=False):
191
+ click.echo(f"Skipping {d.name}")
192
+ continue
193
+
194
+ click.echo(f"Renewing {d.name}...")
195
+ try:
196
+ action_id = client.renew_domain(d.name)
197
+ click.echo(f" Success: {action_id}")
198
+ except HRDError as e:
199
+ click.echo(f" Failed: {e}")
200
+ except Exception as e:
201
+ click.echo(f"Error processing profile {p_name or 'default'}: {e}")
202
+
203
+
204
+ if __name__ == "__main__":
205
+ cli()
@@ -0,0 +1,85 @@
1
+ from typing import List, Optional, Dict, Any
2
+ from datetime import datetime
3
+ from .api import HRDApi
4
+ from .models import Balance, Domain
5
+ from .exceptions import HRDError
6
+
7
+
8
+ class HRDClient:
9
+ def __init__(self, api_login: str, api_pass: str, api_hash: str, **kwargs):
10
+ self.api = HRDApi(api_login, api_pass, api_hash, **kwargs)
11
+
12
+ def login(self) -> str:
13
+ return self.api.login()
14
+
15
+ def get_balance(self) -> Balance:
16
+ data = self.api.partner_get_balance()
17
+ return Balance(balance=data["balance"], restricted_balance=data["restricted_balance"])
18
+
19
+ def list_domains(self) -> List[Domain]:
20
+ domain_names = []
21
+ last_name = None
22
+ while True:
23
+ batch = self.api.domain_list(last_name=last_name)
24
+ if not batch:
25
+ break
26
+ domain_names.extend(batch)
27
+ last_name = batch[-1]
28
+ # HRD API doesn't seem to have a defined limit per page in PHP code,
29
+ # but usually it's limited. If we get the same last_name, we break to avoid infinite loop.
30
+ if len(batch) < 2: # Very simple check for small batch
31
+ break
32
+
33
+ domains = []
34
+ for name in domain_names:
35
+ try:
36
+ info = self.api.domain_info(name)
37
+ domains.append(self._parse_domain_info(name, info))
38
+ except HRDError:
39
+ # If info fails for one domain, we might still want to continue
40
+ domains.append(Domain(name=name, status="unknown"))
41
+
42
+ return domains
43
+
44
+ def _parse_domain_info(self, name: str, info: Dict[str, Any]) -> Domain:
45
+ expiry_date = None
46
+ if info.get("exDate"):
47
+ expiry_date = self._parse_date(info["exDate"])
48
+
49
+ create_date = None
50
+ if info.get("crDate"):
51
+ create_date = self._parse_date(info["crDate"])
52
+
53
+ return Domain(name=name, status=info.get("status", "unknown"), expiry_date=expiry_date, create_date=create_date)
54
+
55
+ def _parse_date(self, date_str: str) -> Optional[datetime]:
56
+ for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
57
+ try:
58
+ return datetime.strptime(date_str, fmt)
59
+ except ValueError:
60
+ continue
61
+ return None
62
+
63
+ def renew_domain(self, domain_name: str, period: int = 1) -> int:
64
+ info = self.api.domain_info(domain_name)
65
+ if not info.get("exDate"):
66
+ raise HRDError(f"Cannot renew domain {domain_name}: expiry date unknown")
67
+
68
+ expiry_date = self._parse_date(info["exDate"])
69
+ if not expiry_date:
70
+ raise HRDError(f"Cannot renew domain {domain_name}: unparseable expiry date '{info['exDate']}'")
71
+
72
+ # API requires currentExpirationDate as a plain YYYY-MM-DD date (no time component)
73
+ return self.api.domain_renew(domain_name, expiry_date.strftime("%Y-%m-%d"), period)
74
+
75
+ def renew_all_expiring(self, days: int = 30) -> Dict[str, int]:
76
+ domains = self.list_domains()
77
+ results = {}
78
+ for domain in domains:
79
+ if domain.is_expiring_soon(days):
80
+ try:
81
+ action_id = self.renew_domain(domain.name)
82
+ results[domain.name] = action_id
83
+ except HRDError:
84
+ results[domain.name] = -1 # or error message
85
+ return results
@@ -0,0 +1,63 @@
1
+ import os
2
+ import yaml
3
+ import stat
4
+ from typing import Dict, Any, Optional, List
5
+
6
+
7
+ class ConfigManager:
8
+ DEFAULT_PATH = os.path.expanduser("~/.config/hrd/config.yaml")
9
+
10
+ def __init__(self, path: Optional[str] = None):
11
+ self.path = path or self.DEFAULT_PATH
12
+ self.config: Dict[str, Any] = {"default_profile": None, "profiles": {}}
13
+ self.load()
14
+
15
+ def load(self):
16
+ if os.path.exists(self.path):
17
+ with open(self.path, "r") as f:
18
+ loaded = yaml.safe_load(f)
19
+ if loaded:
20
+ self.config.update(loaded)
21
+
22
+ def save(self):
23
+ dirname = os.path.dirname(self.path)
24
+ if dirname:
25
+ os.makedirs(dirname, exist_ok=True)
26
+ with open(self.path, "w") as f:
27
+ yaml.safe_dump(self.config, f)
28
+
29
+ # Set secure permissions (600)
30
+ os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
31
+
32
+ def get_profile(self, name: Optional[str] = None) -> Optional[Dict[str, str]]:
33
+ name = name or self.config.get("default_profile")
34
+ if not name:
35
+ return None
36
+ return self.config.get("profiles", {}).get(name)
37
+
38
+ def add_profile(self, name: str, login: str, password: str, api_hash: str):
39
+ if "profiles" not in self.config:
40
+ self.config["profiles"] = {}
41
+
42
+ self.config["profiles"][name] = {"login": login, "password": password, "api_hash": api_hash}
43
+
44
+ if not self.config.get("default_profile"):
45
+ self.config["default_profile"] = name
46
+
47
+ self.save()
48
+
49
+ def set_default(self, name: str):
50
+ if name in self.config.get("profiles", {}):
51
+ self.config["default_profile"] = name
52
+ self.save()
53
+ return True
54
+ return False
55
+
56
+ def list_profiles(self) -> List[str]:
57
+ return list(self.config.get("profiles", {}).keys())
58
+
59
+ def get_all_profiles(self) -> Dict[str, Dict[str, str]]:
60
+ return self.config.get("profiles", {})
61
+
62
+ def get_default_profile_name(self) -> Optional[str]:
63
+ return self.config.get("default_profile")
@@ -0,0 +1,24 @@
1
+ class HRDError(Exception):
2
+ """Base exception for hrd-py"""
3
+
4
+ pass
5
+
6
+
7
+ class HRDCommunicationError(HRDError):
8
+ """Exception raised for communication errors with HRD API"""
9
+
10
+ pass
11
+
12
+
13
+ class HRDAuthError(HRDError):
14
+ """Exception raised for authentication errors"""
15
+
16
+ pass
17
+
18
+
19
+ class HRDAPIError(HRDError):
20
+ """Exception raised when the API returns an error message"""
21
+
22
+ def __init__(self, message, code=None):
23
+ super().__init__(message)
24
+ self.code = code
@@ -0,0 +1,23 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+ from typing import Optional
4
+
5
+
6
+ @dataclass
7
+ class Balance:
8
+ balance: float
9
+ restricted_balance: float
10
+
11
+
12
+ @dataclass
13
+ class Domain:
14
+ name: str
15
+ status: str
16
+ expiry_date: Optional[datetime] = None
17
+ create_date: Optional[datetime] = None
18
+
19
+ def is_expiring_soon(self, days: int = 30) -> bool:
20
+ if not self.expiry_date:
21
+ return False
22
+ delta = self.expiry_date - datetime.now()
23
+ return delta.days <= days
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: hrd-py
3
+ Version: 0.0.1
4
+ Summary: A Python library and CLI for the HRD.pl API
5
+ Author: TheUndefined
6
+ License: MIT
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: click>=8.0.0
9
+ Requires-Dist: python-dotenv>=0.19.0
10
+ Requires-Dist: PyYAML>=6.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
13
+ Requires-Dist: pytest-mock>=3.14.0; extra == "dev"
14
+ Requires-Dist: ruff>=0.3.0; extra == "dev"
15
+ Requires-Dist: black>=24.0.0; extra == "dev"
16
+ Requires-Dist: mypy>=1.9.0; extra == "dev"
17
+ Requires-Dist: types-PyYAML>=6.0.0; extra == "dev"
18
+ Requires-Dist: build>=1.0.0; extra == "dev"
19
+ Requires-Dist: twine>=5.0.0; extra == "dev"
20
+
21
+ # hrd-py
22
+
23
+ A Python library and CLI for the HRD.pl API.
24
+
25
+ ## Features
26
+ - Check account balance.
27
+ - List domains and their status.
28
+ - Monitor domain expiry.
29
+ - Renew domains manually or automatically.
30
+
31
+ ## Installation
32
+ ```bash
33
+ pip install .
34
+ ```
35
+
36
+ ## Configuration
37
+ Create a `.env` file with your HRD.pl credentials:
38
+ ```env
39
+ HRD_LOGIN=your_login
40
+ HRD_PASS=your_api_password
41
+ HRD_HASH=your_api_hash
42
+ ```
43
+
44
+ ## Usage
45
+ ### CLI
46
+ ```bash
47
+ hrd balance
48
+ hrd domains --all
49
+ hrd expiring --days 30
50
+ hrd renew example.com
51
+ hrd auto-renew --days 7
52
+ ```
53
+
54
+ ### Library
55
+ ```python
56
+ from hrd_py import HRDClient
57
+
58
+ client = HRDClient(login, password, api_hash)
59
+ client.login()
60
+ balance = client.get_balance()
61
+ print(f"Balance: {balance.balance}")
62
+ ```
@@ -0,0 +1,17 @@
1
+ README.md
2
+ pyproject.toml
3
+ hrd_py/__init__.py
4
+ hrd_py/api.py
5
+ hrd_py/cli.py
6
+ hrd_py/client.py
7
+ hrd_py/config.py
8
+ hrd_py/exceptions.py
9
+ hrd_py/models.py
10
+ hrd_py.egg-info/PKG-INFO
11
+ hrd_py.egg-info/SOURCES.txt
12
+ hrd_py.egg-info/dependency_links.txt
13
+ hrd_py.egg-info/entry_points.txt
14
+ hrd_py.egg-info/requires.txt
15
+ hrd_py.egg-info/top_level.txt
16
+ tests/test_api.py
17
+ tests/test_client.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hrd = hrd_py.cli:cli
@@ -0,0 +1,13 @@
1
+ click>=8.0.0
2
+ python-dotenv>=0.19.0
3
+ PyYAML>=6.0
4
+
5
+ [dev]
6
+ pytest>=8.0.0
7
+ pytest-mock>=3.14.0
8
+ ruff>=0.3.0
9
+ black>=24.0.0
10
+ mypy>=1.9.0
11
+ types-PyYAML>=6.0.0
12
+ build>=1.0.0
13
+ twine>=5.0.0
@@ -0,0 +1 @@
1
+ hrd_py
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hrd-py"
7
+ version = "0.0.1"
8
+ description = "A Python library and CLI for the HRD.pl API"
9
+ readme = "README.md"
10
+ authors = [{name = "TheUndefined"}]
11
+ license = {text = "MIT"}
12
+ dependencies = [
13
+ "click>=8.0.0",
14
+ "python-dotenv>=0.19.0",
15
+ "PyYAML>=6.0",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ dev = [
20
+ "pytest>=8.0.0",
21
+ "pytest-mock>=3.14.0",
22
+ "ruff>=0.3.0",
23
+ "black>=24.0.0",
24
+ "mypy>=1.9.0",
25
+ "types-PyYAML>=6.0.0",
26
+ "build>=1.0.0",
27
+ "twine>=5.0.0",
28
+ ]
29
+
30
+ [project.scripts]
31
+ hrd = "hrd_py.cli:cli"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["."]
35
+ include = ["hrd_py*"]
36
+
37
+ [tool.black]
38
+ line-length = 120
39
+
40
+ [tool.ruff]
41
+ line-length = 120
42
+ target-version = "py39"
hrd_py-0.0.1/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,55 @@
1
+ import struct
2
+ import hashlib
3
+ from hrd_py.api import HRDApi
4
+
5
+
6
+ def test_api_send_receives_correct_framing(mocker):
7
+ # Mocking socket and ssl
8
+ mocker.patch("socket.create_connection")
9
+ mock_context = mocker.patch("ssl.create_default_context")
10
+ mock_ssl_sock = mock_context.return_value.wrap_socket.return_value
11
+
12
+ api = HRDApi("login", "pass", "deadbeef", verify_peer=False)
13
+
14
+ # Mock response
15
+ xml_response = '<api xmlns="http://api.hrd.pl/api/"><status>ok</status><token>test_token</token></api>'
16
+ resp_bytes = xml_response.encode("utf-8")
17
+ mock_ssl_sock.recv.side_effect = [struct.pack(">I", len(resp_bytes)), resp_bytes]
18
+
19
+ token = api.login()
20
+
21
+ assert token == "test_token"
22
+ assert api.token == "test_token"
23
+
24
+ # Verify what was sent
25
+ sent_data = mock_ssl_sock.sendall.call_args[0][0]
26
+ length = struct.unpack(">I", sent_data[:4])[0]
27
+ hash_sent = sent_data[4:68]
28
+ xml_sent = sent_data[68:].decode("utf-8")
29
+
30
+ assert length == len(sent_data) - 4
31
+ assert "<login>" in xml_sent
32
+
33
+ # Verify hash: SHA512(XML + binary_api_hash)
34
+ h = hashlib.sha512()
35
+ h.update(xml_sent.encode("utf-8"))
36
+ h.update(bytes.fromhex("deadbeef"))
37
+ assert h.digest() == hash_sent
38
+
39
+
40
+ def test_partner_get_balance(mocker):
41
+ api = HRDApi("login", "pass", "deadbeef")
42
+ api.token = "test_token"
43
+
44
+ mock_request = mocker.patch.object(HRDApi, "_request")
45
+
46
+ import xml.etree.ElementTree as ET
47
+
48
+ resp_xml = "<api><partner><getBalance><balance>100.50</balance><restrictedBalance>10.00</restrictedBalance></getBalance></partner></api>"
49
+ mock_request.return_value = ET.fromstring(resp_xml)
50
+
51
+ balance = api.partner_get_balance()
52
+
53
+ assert balance["balance"] == 100.50
54
+ assert balance["restricted_balance"] == 10.00
55
+ mock_request.assert_called_once_with("partner", "getBalance")
@@ -0,0 +1,39 @@
1
+ from datetime import datetime
2
+ from hrd_py.client import HRDClient
3
+ from hrd_py.models import Domain
4
+
5
+
6
+ def test_client_list_domains(mocker):
7
+ client = HRDClient("login", "pass", "deadbeef")
8
+
9
+ mock_api = mocker.patch.object(client, "api")
10
+
11
+ # Mock domain_list responses (pagination)
12
+ mock_api.domain_list.side_effect = [["domain1.pl", "domain2.pl"], []]
13
+
14
+ # Mock domain_info responses
15
+ mock_api.domain_info.side_effect = [
16
+ {"status": "registered", "exDate": "2026-06-30"},
17
+ {"status": "expired", "exDate": "2026-05-01"},
18
+ ]
19
+
20
+ domains = client.list_domains()
21
+
22
+ assert len(domains) == 2
23
+ assert domains[0].name == "domain1.pl"
24
+ assert domains[0].status == "registered"
25
+ assert domains[0].expiry_date == datetime(2026, 6, 30)
26
+ assert domains[1].name == "domain2.pl"
27
+ assert domains[1].status == "expired"
28
+
29
+
30
+ def test_domain_is_expiring_soon():
31
+ import datetime as dt
32
+
33
+ now = datetime.now()
34
+
35
+ d_soon = Domain(name="soon.pl", status="ok", expiry_date=now + dt.timedelta(days=5))
36
+ d_far = Domain(name="far.pl", status="ok", expiry_date=now + dt.timedelta(days=40))
37
+
38
+ assert d_soon.is_expiring_soon() is True
39
+ assert d_far.is_expiring_soon() is False