hive-nectar 0.1.1__py3-none-any.whl → 0.1.3__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.

Potentially problematic release.


This version of hive-nectar might be problematic. Click here for more details.

nectar/comment.py CHANGED
@@ -43,7 +43,7 @@ class Comment(BlockchainObject):
43
43
  >>> from nectar.comment import Comment
44
44
  >>> from nectar.account import Account
45
45
  >>> # Create a Hive blockchain instance
46
- >>> from nectar.blockchain import Blockchain as Hive
46
+ >>> from nectar import Hive
47
47
  >>> hv = Hive()
48
48
  >>> acc = Account("gtg", blockchain_instance=hv)
49
49
  >>> authorperm = acc.get_blog(limit=1)[0]["authorperm"]
@@ -467,9 +467,9 @@ class Comment(BlockchainObject):
467
467
  Amount: Estimated HBD payout required to offset the early-vote curation penalty.
468
468
  """
469
469
  self.refresh()
470
- if self.blockchain.hardfork() >= 21:
470
+ if self.blockchain.hardfork >= 21:
471
471
  reverse_auction_window_seconds = HIVE_REVERSE_AUCTION_WINDOW_SECONDS_HF21
472
- elif self.blockchain.hardfork() >= 20:
472
+ elif self.blockchain.hardfork >= 20:
473
473
  reverse_auction_window_seconds = HIVE_REVERSE_AUCTION_WINDOW_SECONDS_HF20
474
474
  else:
475
475
  reverse_auction_window_seconds = HIVE_REVERSE_AUCTION_WINDOW_SECONDS_HF6
@@ -526,9 +526,9 @@ class Comment(BlockchainObject):
526
526
  elapsed_seconds = (vote_time - self["created"]).total_seconds()
527
527
  else:
528
528
  raise ValueError("vote_time must be a string or a datetime")
529
- if self.blockchain.hardfork() >= 21:
529
+ if self.blockchain.hardfork >= 21:
530
530
  reward = elapsed_seconds / HIVE_REVERSE_AUCTION_WINDOW_SECONDS_HF21
531
- elif self.blockchain.hardfork() >= 20:
531
+ elif self.blockchain.hardfork >= 20:
532
532
  reward = elapsed_seconds / HIVE_REVERSE_AUCTION_WINDOW_SECONDS_HF20
533
533
  else:
534
534
  reward = elapsed_seconds / HIVE_REVERSE_AUCTION_WINDOW_SECONDS_HF6
@@ -675,7 +675,7 @@ class Comment(BlockchainObject):
675
675
  curation_tokens = self.reward * author_reward_factor
676
676
  author_tokens = self.reward - curation_tokens
677
677
  curation_rewards = self.get_curation_rewards()
678
- if self.blockchain.hardfork() >= 20 and median_hist is not None:
678
+ if self.blockchain.hardfork >= 20 and median_hist is not None:
679
679
  author_tokens += median_price * curation_rewards["unclaimed_rewards"]
680
680
  benefactor_tokens = author_tokens * beneficiaries_pct / HIVE_100_PERCENT
681
681
  author_tokens -= benefactor_tokens
@@ -1209,9 +1209,10 @@ class RankedPosts(list):
1209
1209
  if not self.blockchain.is_connected():
1210
1210
  return None
1211
1211
  comments = []
1212
- api_limit = limit
1213
- if api_limit > 100:
1214
- api_limit = 100
1212
+ # Bridge API enforces a maximum page size (typically 20). Cap
1213
+ # per-request size accordingly and page until `limit` is reached.
1214
+ # Previously capped to 100 which can trigger Invalid parameters.
1215
+ api_limit = min(limit, 20)
1215
1216
  last_n = -1
1216
1217
  while len(comments) < limit and last_n != len(comments):
1217
1218
  last_n = len(comments)
@@ -1248,8 +1249,9 @@ class RankedPosts(list):
1248
1249
  if len(comments) > 0:
1249
1250
  start_author = comments[-1]["author"]
1250
1251
  start_permlink = comments[-1]["permlink"]
1251
- if limit - len(comments) < 100:
1252
- api_limit = limit - len(comments) + 1
1252
+ # Recompute per-request limit, capped to bridge max (20)
1253
+ remaining = limit - len(comments)
1254
+ api_limit = min(20, remaining + 1) if remaining < 20 else 20
1253
1255
  except Exception as e:
1254
1256
  # If we get an error but have some posts, return what we have
1255
1257
  if len(comments) > 0:
@@ -1312,9 +1314,9 @@ class AccountPosts(list):
1312
1314
  if not self.blockchain.is_connected():
1313
1315
  return None
1314
1316
  comments = []
1315
- api_limit = limit
1316
- if api_limit > 100:
1317
- api_limit = 100
1317
+ # Bridge API typically restricts page size to 20; cap per-request size
1318
+ # and page as needed until overall `limit` is satisfied.
1319
+ api_limit = min(limit, 20)
1318
1320
  last_n = -1
1319
1321
  while len(comments) < limit and last_n != len(comments):
1320
1322
  last_n = len(comments)
@@ -1351,8 +1353,9 @@ class AccountPosts(list):
1351
1353
  if len(comments) > 0:
1352
1354
  start_author = comments[-1]["author"]
1353
1355
  start_permlink = comments[-1]["permlink"]
1354
- if limit - len(comments) < 100:
1355
- api_limit = limit - len(comments) + 1
1356
+ # Recompute per-request limit for next page, still capped at 20
1357
+ remaining = limit - len(comments)
1358
+ api_limit = min(20, remaining + 1) if remaining < 20 else 20
1356
1359
  except Exception as e:
1357
1360
  # If we get an error but have some posts, return what we have
1358
1361
  if len(comments) > 0:
nectar/haf.py ADDED
@@ -0,0 +1,380 @@
1
+ # -*- coding: utf-8 -*-
2
+ import json
3
+ import logging
4
+ from typing import Any, Dict, Optional
5
+
6
+ import requests
7
+
8
+ from nectar.instance import shared_blockchain_instance
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+
13
+ class HAF(object):
14
+ """Hive Account Feed (HAF) API client for accessing Hive blockchain endpoints.
15
+
16
+ This class provides access to various Hive API endpoints that are not part of the
17
+ standard RPC blockchain calls. It supports multiple API providers and handles
18
+ reputation queries and other HAF-related data.
19
+
20
+ :param str api: Base API URL to use for requests. Supported endpoints include:
21
+ - 'https://api.hive.blog' (default)
22
+ - 'https://api.syncad.com'
23
+ :param blockchain_instance: Blockchain instance for compatibility with the nectar ecosystem
24
+
25
+ .. code-block:: python
26
+
27
+ >>> from nectar.haf import HAF
28
+ >>> haf = HAF() # doctest: +SKIP
29
+ >>> reputation = haf.reputation("thecrazygm") # doctest: +SKIP
30
+ >>> print(reputation) # doctest: +SKIP
31
+
32
+ """
33
+
34
+ DEFAULT_APIS = ["https://api.hive.blog", "https://api.syncad.com"]
35
+
36
+ def __init__(
37
+ self, api: Optional[str] = None, blockchain_instance=None, timeout: Optional[float] = None
38
+ ):
39
+ """
40
+ Initialize the HAF client.
41
+
42
+ Parameters:
43
+ api (str, optional): Base API URL. If None, uses the first available default API.
44
+ blockchain_instance: Blockchain instance for ecosystem compatibility.
45
+ timeout (float, optional): Timeout for requests in seconds.
46
+ """
47
+ self.api = api or self.DEFAULT_APIS[0]
48
+ self.blockchain = blockchain_instance or shared_blockchain_instance()
49
+ self._timeout = float(timeout) if timeout else 30.0
50
+
51
+ # Validate API URL
52
+ if not self.api.startswith(("http://", "https://")):
53
+ raise ValueError(f"Invalid API URL: {self.api}. Must start with http:// or https://")
54
+
55
+ # Remove trailing slash if present
56
+ self.api = self.api.rstrip("/")
57
+
58
+ log.debug(f"Initialized HAF client with API: {self.api}")
59
+
60
+ def _make_request(self, endpoint: str, method: str = "GET", **kwargs) -> Any:
61
+ """
62
+ Make an HTTP request to the HAF API.
63
+
64
+ Parameters:
65
+ endpoint (str): API endpoint path (without leading slash)
66
+ method (str): HTTP method (default: 'GET')
67
+ **kwargs: Additional arguments passed to requests
68
+
69
+ Returns:
70
+ dict: JSON response from the API
71
+
72
+ Raises:
73
+ requests.RequestException: If the request fails
74
+ ValueError: If the response is not valid JSON
75
+ """
76
+ url = f"{self.api}/{endpoint}"
77
+
78
+ # Set default headers
79
+ headers = kwargs.pop("headers", {})
80
+ headers.setdefault("accept", "application/json")
81
+ headers.setdefault("User-Agent", "hive-nectar/0.1.3")
82
+
83
+ log.debug(f"Making {method} request to: {url}")
84
+
85
+ try:
86
+ timeout = kwargs.pop("timeout", self._timeout)
87
+ response = requests.request(method, url, headers=headers, timeout=timeout, **kwargs)
88
+ response.raise_for_status()
89
+
90
+ return response.json()
91
+
92
+ except requests.RequestException as e:
93
+ log.error(f"Request failed for {url}: {e}")
94
+ raise
95
+ except json.JSONDecodeError as e:
96
+ log.error(f"Invalid JSON response from {url}: {e}")
97
+ raise ValueError(f"Invalid JSON response from API: {e}")
98
+
99
+ def reputation(self, account: str) -> Optional[Dict[str, Any]]:
100
+ """
101
+ Get reputation information for a Hive account.
102
+
103
+ This method queries the reputation API endpoint to retrieve the account's
104
+ reputation score and related metadata.
105
+
106
+ Parameters:
107
+ account (str): Hive account name
108
+
109
+ Returns:
110
+ dict or None: Reputation data containing account information and reputation score,
111
+ or None if the request fails or account is not found
112
+
113
+ Example:
114
+ >>> haf = HAF() # doctest: +SKIP
115
+ >>> rep = haf.reputation("thecrazygm") # doctest: +SKIP
116
+ >>> print(rep) # doctest: +SKIP
117
+ {'account': 'thecrazygm', 'reputation': '71', ...}
118
+
119
+ """
120
+ if not account or not isinstance(account, str):
121
+ raise ValueError("Account name must be a non-empty string")
122
+
123
+ try:
124
+ endpoint = f"reputation-api/accounts/{account}/reputation"
125
+ response = self._make_request(endpoint)
126
+
127
+ log.debug(f"Retrieved reputation for account: {account}")
128
+ return response
129
+
130
+ except requests.RequestException as e:
131
+ log.warning(f"Failed to retrieve reputation for account {account}: {e}")
132
+ return None
133
+ except Exception as e:
134
+ log.error(f"Unexpected error retrieving reputation for {account}: {e}")
135
+ return None
136
+
137
+ def get_available_apis(self) -> list[str]:
138
+ """
139
+ Get the list of available API endpoints.
140
+
141
+ Returns:
142
+ list: List of supported API URLs
143
+ """
144
+ return self.DEFAULT_APIS.copy()
145
+
146
+ def set_api(self, api: str) -> None:
147
+ """
148
+ Change the API endpoint.
149
+
150
+ Parameters:
151
+ api (str): New API URL to use
152
+
153
+ Raises:
154
+ ValueError: If the API URL is invalid
155
+ """
156
+ if api not in self.DEFAULT_APIS:
157
+ log.warning(f"Using non-default API: {api}")
158
+
159
+ # Validate URL
160
+ if not api.startswith(("http://", "https://")):
161
+ raise ValueError(f"Invalid API URL: {api}. Must start with http:// or https://")
162
+
163
+ self.api = api.rstrip("/")
164
+ log.info(f"Switched to API: {self.api}")
165
+
166
+ def get_current_api(self) -> str:
167
+ """
168
+ Get the currently active API endpoint.
169
+
170
+ Returns:
171
+ str: Current API URL
172
+ """
173
+ return self.api
174
+
175
+ def get_account_balances(self, account: str) -> Optional[Dict[str, Any]]:
176
+ """
177
+ Get account balances from the balance API.
178
+
179
+ This method retrieves comprehensive balance information including HBD, HIVE,
180
+ vesting shares, rewards, and other balance-related data for an account.
181
+
182
+ Parameters:
183
+ account (str): Hive account name
184
+
185
+ Returns:
186
+ dict or None: Account balance data or None if request fails
187
+
188
+ Example:
189
+ >>> haf = HAF() # doctest: +SKIP
190
+ >>> balances = haf.get_account_balances("thecrazygm") # doctest: +SKIP
191
+ >>> print(balances['hive_balance']) # doctest: +SKIP
192
+ """
193
+ if not account or not isinstance(account, str):
194
+ raise ValueError("Account name must be a non-empty string")
195
+
196
+ try:
197
+ endpoint = f"balance-api/accounts/{account}/balances"
198
+ response = self._make_request(endpoint)
199
+
200
+ log.debug(f"Retrieved balances for account: {account}")
201
+ return response
202
+
203
+ except requests.RequestException as e:
204
+ log.warning(f"Failed to retrieve balances for account {account}: {e}")
205
+ return None
206
+ except Exception as e:
207
+ log.error(f"Unexpected error retrieving balances for {account}: {e}")
208
+ return None
209
+
210
+ def get_account_delegations(self, account: str) -> Optional[Dict[str, Any]]:
211
+ """
212
+ Get account delegations from the balance API.
213
+
214
+ This method retrieves both incoming and outgoing delegations for an account.
215
+
216
+ Parameters:
217
+ account (str): Hive account name
218
+
219
+ Returns:
220
+ dict or None: Delegation data containing incoming and outgoing delegations
221
+
222
+ Example:
223
+ >>> haf = HAF() # doctest: +SKIP
224
+ >>> delegations = haf.get_account_delegations("thecrazygm") # doctest: +SKIP
225
+ >>> print(delegations['incoming_delegations']) # doctest: +SKIP
226
+ """
227
+ if not account or not isinstance(account, str):
228
+ raise ValueError("Account name must be a non-empty string")
229
+
230
+ try:
231
+ endpoint = f"balance-api/accounts/{account}/delegations"
232
+ response = self._make_request(endpoint)
233
+
234
+ log.debug(f"Retrieved delegations for account: {account}")
235
+ return response
236
+
237
+ except requests.RequestException as e:
238
+ log.warning(f"Failed to retrieve delegations for account {account}: {e}")
239
+ return None
240
+ except Exception as e:
241
+ log.error(f"Unexpected error retrieving delegations for {account}: {e}")
242
+ return None
243
+
244
+ def get_account_recurrent_transfers(self, account: str) -> Optional[Dict[str, Any]]:
245
+ """
246
+ Get account recurrent transfers from the balance API.
247
+
248
+ This method retrieves both incoming and outgoing recurrent transfers for an account.
249
+
250
+ Parameters:
251
+ account (str): Hive account name
252
+
253
+ Returns:
254
+ dict or None: Recurrent transfer data containing incoming and outgoing transfers
255
+
256
+ Example:
257
+ >>> haf = HAF() # doctest: +SKIP
258
+ >>> transfers = haf.get_account_recurrent_transfers("thecrazygm") # doctest: +SKIP
259
+ >>> print(transfers['outgoing_recurrent_transfers']) # doctest: +SKIP
260
+ """
261
+ if not account or not isinstance(account, str):
262
+ raise ValueError("Account name must be a non-empty string")
263
+
264
+ try:
265
+ endpoint = f"balance-api/accounts/{account}/recurrent-transfers"
266
+ response = self._make_request(endpoint)
267
+
268
+ log.debug(f"Retrieved recurrent transfers for account: {account}")
269
+ return response
270
+
271
+ except requests.RequestException as e:
272
+ log.warning(f"Failed to retrieve recurrent transfers for account {account}: {e}")
273
+ return None
274
+ except Exception as e:
275
+ log.error(f"Unexpected error retrieving recurrent transfers for {account}: {e}")
276
+ return None
277
+
278
+ def get_reputation_version(self) -> Optional[str]:
279
+ """
280
+ Get the reputation tracker's version from the reputation API.
281
+
282
+ Returns:
283
+ str or None: Version string or None if request fails
284
+
285
+ Example:
286
+ >>> haf = HAF() # doctest: +SKIP
287
+ >>> version = haf.get_reputation_version() # doctest: +SKIP
288
+ >>> print(version) # doctest: +SKIP
289
+ """
290
+ try:
291
+ endpoint = "reputation-api/version"
292
+ response = self._make_request(endpoint)
293
+
294
+ log.debug("Retrieved reputation tracker version")
295
+ return response
296
+
297
+ except requests.RequestException as e:
298
+ log.warning(f"Failed to retrieve reputation version: {e}")
299
+ return None
300
+ except Exception as e:
301
+ log.error(f"Unexpected error retrieving reputation version: {e}")
302
+ return None
303
+
304
+ def get_reputation_last_synced_block(self) -> Optional[int]:
305
+ """
306
+ Get the last block number synced by the reputation tracker.
307
+
308
+ Returns:
309
+ int or None: Last synced block number or None if request fails
310
+
311
+ Example:
312
+ >>> haf = HAF() # doctest: +SKIP
313
+ >>> block = haf.get_reputation_last_synced_block() # doctest: +SKIP
314
+ >>> print(block) # doctest: +SKIP
315
+ """
316
+ try:
317
+ endpoint = "reputation-api/last-synced-block"
318
+ response = self._make_request(endpoint)
319
+
320
+ log.debug("Retrieved last synced block for reputation tracker")
321
+ return response
322
+
323
+ except requests.RequestException as e:
324
+ log.warning(f"Failed to retrieve last synced block: {e}")
325
+ return None
326
+ except Exception as e:
327
+ log.error(f"Unexpected error retrieving last synced block: {e}")
328
+ return None
329
+
330
+ def get_balance_version(self) -> Optional[str]:
331
+ """
332
+ Get the balance tracker's version from the balance API.
333
+
334
+ Returns:
335
+ str or None: Version string or None if request fails
336
+
337
+ Example:
338
+ >>> haf = HAF() # doctest: +SKIP
339
+ >>> version = haf.get_balance_version() # doctest: +SKIP
340
+ >>> print(version) # doctest: +SKIP
341
+ """
342
+ try:
343
+ endpoint = "balance-api/version"
344
+ response = self._make_request(endpoint)
345
+
346
+ log.debug("Retrieved balance tracker version")
347
+ return response
348
+
349
+ except requests.RequestException as e:
350
+ log.warning(f"Failed to retrieve balance version: {e}")
351
+ return None
352
+ except Exception as e:
353
+ log.error(f"Unexpected error retrieving balance version: {e}")
354
+ return None
355
+
356
+ def get_balance_last_synced_block(self) -> Optional[int]:
357
+ """
358
+ Get the last block number synced by the balance tracker.
359
+
360
+ Returns:
361
+ int or None: Last synced block number or None if request fails
362
+
363
+ Example:
364
+ >>> haf = HAF() # doctest: +SKIP
365
+ >>> block = haf.get_balance_last_synced_block() # doctest: +SKIP
366
+ >>> print(block) # doctest: +SKIP
367
+ """
368
+ try:
369
+ endpoint = "balance-api/last-synced-block"
370
+ response = self._make_request(endpoint)
371
+
372
+ log.debug("Retrieved last synced block for balance tracker")
373
+ return response
374
+
375
+ except requests.RequestException as e:
376
+ log.warning(f"Failed to retrieve last synced block: {e}")
377
+ return None
378
+ except Exception as e:
379
+ log.error(f"Unexpected error retrieving last synced block: {e}")
380
+ return None
nectar/hivesigner.py CHANGED
@@ -374,6 +374,15 @@ class HiveSigner(object):
374
374
 
375
375
  return r.json()
376
376
 
377
+ def create_hot_sign_url(self, operation, params, redirect_uri=None):
378
+ url = urljoin(self.hs_oauth_base_url, "sign/" + operation)
379
+ if redirect_uri:
380
+ params["redirect_uri"] = redirect_uri
381
+ if PY2:
382
+ return url + "?" + urlencode(params).replace("%2C", ",")
383
+ else:
384
+ return url + "?" + urlencode(params, safe=",")
385
+
377
386
  def url_from_tx(self, tx, redirect_uri=None):
378
387
  """
379
388
  Generate HiveSigner hot-sign URLs for each operation in a transaction.
nectar/market.py CHANGED
@@ -330,9 +330,8 @@ class Market(dict):
330
330
  :param int limit: Defines how many trades are fetched at each intervall point
331
331
  :param bool raw_data: when True, the raw data are returned
332
332
  """
333
- utc = timezone.utc
334
333
  if not stop:
335
- stop = utc.localize(datetime.now())
334
+ stop = datetime.now(timezone.utc)
336
335
  if not start:
337
336
  start = stop - timedelta(hours=1)
338
337
  start = addTzInfo(start)
@@ -370,9 +369,8 @@ class Market(dict):
370
369
  """
371
370
  # FIXME, this call should also return whether it was a buy or
372
371
  # sell
373
- utc = timezone.utc
374
372
  if not stop:
375
- stop = utc.localize(datetime.now())
373
+ stop = datetime.now(timezone.utc)
376
374
  if not start:
377
375
  start = stop - timedelta(hours=24)
378
376
  start = addTzInfo(start)
nectar/memo.py CHANGED
@@ -262,7 +262,7 @@ class Memo(object):
262
262
  )
263
263
  enc = unhexlify(base58decode(enc[1:]))
264
264
  shared_secret = BtsMemo.get_shared_secret(priv, pub)
265
- aes, check = BtsMemo.init_aes(shared_secret, nonce)
265
+ aes, check = BtsMemo.init_aes2(shared_secret, nonce)
266
266
  with open(outfile, "wb") as fout:
267
267
  fout.write(struct.pack("<Q", len(enc)))
268
268
  fout.write(enc)
@@ -449,7 +449,7 @@ class Memo(object):
449
449
  nectar_version = BtsMemo.decode_memo(priv, memo)
450
450
  shared_secret = BtsMemo.get_shared_secret(priv, pubkey)
451
451
  # Init encryption
452
- aes, checksum = BtsMemo.init_aes(shared_secret, nonce)
452
+ aes, checksum = BtsMemo.init_aes2(shared_secret, nonce)
453
453
  with open(infile, "rb") as fin:
454
454
  memo_size = struct.unpack("<Q", fin.read(struct.calcsize("<Q")))[0]
455
455
  memo = fin.read(memo_size)
nectar/nodelist.py CHANGED
@@ -130,22 +130,6 @@ class NodeList(list):
130
130
  "hive": True,
131
131
  "score": 40,
132
132
  },
133
- {
134
- "url": "https://hive-api.dlux.io",
135
- "version": "1.27.8",
136
- "type": "appbase",
137
- "owner": "dlux",
138
- "hive": True,
139
- "score": 30,
140
- },
141
- {
142
- "url": "https://api.hive.blue",
143
- "version": "1.27.5",
144
- "type": "appbase",
145
- "owner": "hive.blue",
146
- "hive": True,
147
- "score": 30,
148
- },
149
133
  {
150
134
  "url": "https://hiveapi.actifit.io",
151
135
  "version": "1.27.8",
@@ -162,14 +146,6 @@ class NodeList(list):
162
146
  "hive": True,
163
147
  "score": 20,
164
148
  },
165
- {
166
- "url": "https://hive-test-beeabode.roelandp.nl",
167
- "version": "0.23.0",
168
- "type": "testnet",
169
- "owner": "roelandp",
170
- "hive": True,
171
- "score": 5,
172
- },
173
149
  ]
174
150
  super(NodeList, self).__init__(nodes)
175
151
 
nectar/utils.py CHANGED
@@ -318,44 +318,67 @@ def findall_patch_hunks(body=None):
318
318
 
319
319
 
320
320
  def derive_beneficiaries(beneficiaries):
321
- beneficiaries_list = []
322
- beneficiaries_accounts = []
323
- beneficiaries_sum = 0
324
- if not isinstance(beneficiaries, list):
325
- beneficiaries = beneficiaries.split(",")
326
-
327
- for w in beneficiaries:
328
- account_name = w.strip().split(":")[0]
329
- if account_name[0] == "@":
330
- account_name = account_name[1:]
331
- if account_name in beneficiaries_accounts:
321
+ """
322
+ Parse beneficiaries and return a normalized, merged list of unique accounts with weights in basis points.
323
+
324
+ Accepts a comma-separated string or list with items like "account:10", "@account:10%", or "account" (unknown
325
+ percentage). Duplicate accounts are merged by summing their explicit percentages and any share of the remaining
326
+ percentage allocated to unknown entries. Unknown entries are distributed equally across all unknown slots.
327
+
328
+ Returns a list of dicts sorted by account name: [{"account": str, "weight": int_basis_points}]
329
+ where weight is expressed in basis points (e.g., 1000 == 10%).
330
+ """
331
+ # Normalize input to list of entries
332
+ entries = beneficiaries if isinstance(beneficiaries, list) else beneficiaries.split(",")
333
+
334
+ # Collect known percentages and unknown slots per account
335
+ accounts = {}
336
+ total_known_bp = 0 # basis points (1% == 100)
337
+ total_unknown_slots = 0
338
+
339
+ for raw in entries:
340
+ token = raw.strip()
341
+ if not token:
332
342
  continue
333
- if w.find(":") == -1:
334
- percentage = -1
335
- else:
336
- percentage = w.strip().split(":")[1]
337
- if "%" in percentage:
338
- percentage = percentage.strip().split("%")[0].strip()
339
- percentage = float(percentage)
340
- beneficiaries_sum += percentage
341
- beneficiaries_list.append({"account": account_name, "weight": int(percentage * 100)})
342
- beneficiaries_accounts.append(account_name)
343
-
344
- missing = 0
345
- for bene in beneficiaries_list:
346
- if bene["weight"] < 0:
347
- missing += 1
348
- index = 0
349
- for bene in beneficiaries_list:
350
- if bene["weight"] < 0:
351
- beneficiaries_list[index]["weight"] = int(
352
- (int(100 * 100) - int(beneficiaries_sum * 100)) / missing
353
- )
354
- index += 1
355
- sorted_beneficiaries = sorted(
356
- beneficiaries_list, key=lambda beneficiaries_list: beneficiaries_list["account"]
357
- )
358
- return sorted_beneficiaries
343
+ name_part = token.split(":")[0].strip()
344
+ account = name_part[1:] if name_part.startswith("@") else name_part
345
+ if account not in accounts:
346
+ accounts[account] = {"known_bp": 0, "unknown_slots": 0}
347
+
348
+ if ":" not in token:
349
+ # Unknown slot for this account
350
+ accounts[account]["unknown_slots"] += 1
351
+ total_unknown_slots += 1
352
+ continue
353
+
354
+ # Parse percentage
355
+ perc_str = token.split(":", 1)[1].strip()
356
+ if perc_str.endswith("%"):
357
+ perc_str = perc_str[:-1].strip()
358
+ try:
359
+ perc = float(perc_str)
360
+ except Exception:
361
+ # Treat unparsable as unknown slot
362
+ accounts[account]["unknown_slots"] += 1
363
+ total_unknown_slots += 1
364
+ continue
365
+ bp = int(perc * 100)
366
+ accounts[account]["known_bp"] += bp
367
+ total_known_bp += bp
368
+
369
+ # Distribute remaining to unknown slots equally (in bp)
370
+ remaining_bp = max(0, 10000 - total_known_bp)
371
+ if total_unknown_slots > 0 and remaining_bp > 0:
372
+ for account, data in accounts.items():
373
+ slots = data["unknown_slots"]
374
+ if slots > 0:
375
+ share_bp = int((remaining_bp * slots) / total_unknown_slots)
376
+ data["known_bp"] += share_bp
377
+
378
+ # Build final list (unique accounts) and sort deterministically
379
+ result = [{"account": acc, "weight": data["known_bp"]} for acc, data in accounts.items()]
380
+ result.sort(key=lambda x: x["account"])
381
+ return result
359
382
 
360
383
 
361
384
  def derive_tags(tags):
nectar/version.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """THIS FILE IS GENERATED FROM nectar PYPROJECT.TOML."""
2
2
 
3
- version = "0.1.1"
3
+ version = "0.1.3"