hive-nectar 0.2.9__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.
Files changed (87) hide show
  1. hive_nectar-0.2.9.dist-info/METADATA +194 -0
  2. hive_nectar-0.2.9.dist-info/RECORD +87 -0
  3. hive_nectar-0.2.9.dist-info/WHEEL +4 -0
  4. hive_nectar-0.2.9.dist-info/entry_points.txt +2 -0
  5. hive_nectar-0.2.9.dist-info/licenses/LICENSE.txt +23 -0
  6. nectar/__init__.py +37 -0
  7. nectar/account.py +5076 -0
  8. nectar/amount.py +553 -0
  9. nectar/asciichart.py +303 -0
  10. nectar/asset.py +122 -0
  11. nectar/block.py +574 -0
  12. nectar/blockchain.py +1242 -0
  13. nectar/blockchaininstance.py +2590 -0
  14. nectar/blockchainobject.py +263 -0
  15. nectar/cli.py +5937 -0
  16. nectar/comment.py +1552 -0
  17. nectar/community.py +854 -0
  18. nectar/constants.py +95 -0
  19. nectar/discussions.py +1437 -0
  20. nectar/exceptions.py +152 -0
  21. nectar/haf.py +381 -0
  22. nectar/hive.py +630 -0
  23. nectar/imageuploader.py +114 -0
  24. nectar/instance.py +113 -0
  25. nectar/market.py +876 -0
  26. nectar/memo.py +542 -0
  27. nectar/message.py +379 -0
  28. nectar/nodelist.py +309 -0
  29. nectar/price.py +603 -0
  30. nectar/profile.py +74 -0
  31. nectar/py.typed +0 -0
  32. nectar/rc.py +333 -0
  33. nectar/snapshot.py +1024 -0
  34. nectar/storage.py +62 -0
  35. nectar/transactionbuilder.py +659 -0
  36. nectar/utils.py +630 -0
  37. nectar/version.py +3 -0
  38. nectar/vote.py +722 -0
  39. nectar/wallet.py +472 -0
  40. nectar/witness.py +728 -0
  41. nectarapi/__init__.py +12 -0
  42. nectarapi/exceptions.py +126 -0
  43. nectarapi/graphenerpc.py +596 -0
  44. nectarapi/node.py +194 -0
  45. nectarapi/noderpc.py +79 -0
  46. nectarapi/openapi.py +107 -0
  47. nectarapi/py.typed +0 -0
  48. nectarapi/rpcutils.py +98 -0
  49. nectarapi/version.py +3 -0
  50. nectarbase/__init__.py +15 -0
  51. nectarbase/ledgertransactions.py +106 -0
  52. nectarbase/memo.py +242 -0
  53. nectarbase/objects.py +521 -0
  54. nectarbase/objecttypes.py +21 -0
  55. nectarbase/operationids.py +102 -0
  56. nectarbase/operations.py +1357 -0
  57. nectarbase/py.typed +0 -0
  58. nectarbase/signedtransactions.py +89 -0
  59. nectarbase/transactions.py +11 -0
  60. nectarbase/version.py +3 -0
  61. nectargraphenebase/__init__.py +27 -0
  62. nectargraphenebase/account.py +1121 -0
  63. nectargraphenebase/aes.py +49 -0
  64. nectargraphenebase/base58.py +197 -0
  65. nectargraphenebase/bip32.py +575 -0
  66. nectargraphenebase/bip38.py +110 -0
  67. nectargraphenebase/chains.py +15 -0
  68. nectargraphenebase/dictionary.py +2 -0
  69. nectargraphenebase/ecdsasig.py +309 -0
  70. nectargraphenebase/objects.py +130 -0
  71. nectargraphenebase/objecttypes.py +8 -0
  72. nectargraphenebase/operationids.py +5 -0
  73. nectargraphenebase/operations.py +25 -0
  74. nectargraphenebase/prefix.py +13 -0
  75. nectargraphenebase/py.typed +0 -0
  76. nectargraphenebase/signedtransactions.py +221 -0
  77. nectargraphenebase/types.py +557 -0
  78. nectargraphenebase/unsignedtransactions.py +288 -0
  79. nectargraphenebase/version.py +3 -0
  80. nectarstorage/__init__.py +57 -0
  81. nectarstorage/base.py +317 -0
  82. nectarstorage/exceptions.py +15 -0
  83. nectarstorage/interfaces.py +244 -0
  84. nectarstorage/masterpassword.py +237 -0
  85. nectarstorage/py.typed +0 -0
  86. nectarstorage/ram.py +27 -0
  87. nectarstorage/sqlite.py +343 -0
nectar/comment.py ADDED
@@ -0,0 +1,1552 @@
1
+ import json
2
+ import logging
3
+ import math
4
+ from datetime import date, datetime, timedelta, timezone
5
+ from typing import Any, Dict, Optional, Union
6
+
7
+ from nectar.constants import (
8
+ HIVE_100_PERCENT,
9
+ HIVE_REVERSE_AUCTION_WINDOW_SECONDS_HF6,
10
+ HIVE_REVERSE_AUCTION_WINDOW_SECONDS_HF20,
11
+ HIVE_REVERSE_AUCTION_WINDOW_SECONDS_HF21,
12
+ )
13
+ from nectarbase import operations
14
+
15
+ from .account import Account
16
+ from .amount import Amount
17
+ from .blockchainobject import BlockchainObject
18
+ from .exceptions import ContentDoesNotExistsException, VotingInvalidOnArchivedPost
19
+ from .instance import shared_blockchain_instance
20
+ from .price import Price
21
+ from .utils import (
22
+ construct_authorperm,
23
+ formatTimeString,
24
+ formatToTimeStamp,
25
+ make_patch,
26
+ parse_time,
27
+ resolve_authorperm,
28
+ )
29
+
30
+ log = logging.getLogger(__name__)
31
+
32
+
33
+ class Comment(BlockchainObject):
34
+ """Read data about a Comment/Post in the chain
35
+
36
+ :param str authorperm: identifier to post/comment in the form of
37
+ ``@author/permlink``
38
+ :param str tags: defines which api is used. Can be bridge, tags, condenser or database (default = bridge)
39
+ :param Blockchain blockchain_instance: Blockchain instance to use when accessing the RPC
40
+
41
+
42
+ .. code-block:: python
43
+
44
+ >>> from nectar.comment import Comment
45
+ >>> from nectar.account import Account
46
+ >>> # Create a Hive blockchain instance
47
+ >>> from nectar import Hive
48
+ >>> hv = Hive()
49
+ >>> acc = Account("gtg", blockchain_instance=hv)
50
+ >>> authorperm = acc.get_blog(limit=1)[0]["authorperm"]
51
+ >>> c = Comment(authorperm)
52
+ >>> postdate = c["created"]
53
+ >>> postdate_str = c.json()["created"]
54
+
55
+ """
56
+
57
+ type_id = 8
58
+
59
+ def __init__(
60
+ self,
61
+ authorperm: Union[str, Dict[str, Any]],
62
+ api: str = "bridge",
63
+ observer: str = "",
64
+ full: bool = True,
65
+ lazy: bool = False,
66
+ blockchain_instance: Optional[Any] = None,
67
+ ) -> None:
68
+ """
69
+ Create a Comment object representing a Hive post or comment.
70
+
71
+ Supports initializing from either an author/permlink string ("author/permlink") or a dict containing at least "author" and "permlink". For a string input the constructor resolves and stores author, permlink, and authorperm. For a dict input the constructor normalizes the dict via _parse_json_data (timestamps, amounts, metadata) and sets the canonical "authorperm" before delegating to the BlockchainObject constructor.
72
+
73
+ Parameters:
74
+ authorperm: Either an "author/permlink" string or a dict with "author" and "permlink".
75
+ api: RPC bridge to use (defaults to "bridge"); stored on the instance.
76
+ observer: Optional observer identifier stored on the instance.
77
+ full: If True, load all fields immediately; if False, allow partial/lazy loading.
78
+ lazy: If True, delay full object loading until needed.
79
+
80
+ Note: The blockchain instance is taken from blockchain_instance (if provided) or the module's shared_blockchain_instance(). The constructor sets instance attributes and then calls the parent initializer with id_item="authorperm".
81
+ """
82
+ self.full = full
83
+ self.lazy = lazy
84
+ self.api = api
85
+ self.observer = observer
86
+ self.blockchain = blockchain_instance or shared_blockchain_instance()
87
+ if isinstance(authorperm, str) and authorperm != "":
88
+ [author, permlink] = resolve_authorperm(authorperm)
89
+ self["id"] = 0
90
+ self["author"] = author
91
+ self["permlink"] = permlink
92
+ self["authorperm"] = authorperm
93
+ elif isinstance(authorperm, dict) and "author" in authorperm and "permlink" in authorperm:
94
+ authorperm["authorperm"] = construct_authorperm(
95
+ authorperm["author"], authorperm["permlink"]
96
+ )
97
+ authorperm = self._parse_json_data(authorperm)
98
+ super().__init__(
99
+ authorperm,
100
+ id_item="authorperm",
101
+ lazy=lazy,
102
+ full=full,
103
+ blockchain_instance=self.blockchain,
104
+ )
105
+
106
+ def _parse_json_data(self, comment: Dict[str, Any]) -> Dict[str, Any]:
107
+ """
108
+ Normalize and convert raw comment JSON fields into Python-native types.
109
+
110
+ This parses and mutates the given comment dict in-place and returns it. Normalizations:
111
+ - Converts known timestamp strings (e.g., "created", "last_update", "cashout_time") to datetime using formatTimeString.
112
+ - Converts monetary fields backed by the chain's backed token (HBD) into Amount objects using the instance's backed_token_symbol.
113
+ - Ensures a "community" key exists and parses `json_metadata` (string/bytes) into a dict; extracts `tags` and `community` from that metadata when present.
114
+ - Converts numeric string fields like `author_reputation` and `net_rshares` to ints.
115
+ - Normalizes each entry in `active_votes`: converts vote `time` to datetime and numeric strings (`rshares`, `reputation`) to ints (falling back to 0 on parse errors).
116
+
117
+ Parameters:
118
+ comment (dict): Raw comment/post data as returned by the node RPC.
119
+
120
+ Returns:
121
+ dict: The same comment dict with normalized fields (timestamps as datetimes, amounts as Amount objects, json_metadata as dict, numeric fields as ints).
122
+ """
123
+ parse_times = [
124
+ "active",
125
+ "cashout_time",
126
+ "created",
127
+ "last_payout",
128
+ "last_update",
129
+ "updated",
130
+ "max_cashout_time",
131
+ ]
132
+ for p in parse_times:
133
+ if p in comment and isinstance(comment.get(p), str):
134
+ comment[p] = parse_time(comment.get(p, "1970-01-01T00:00:00"))
135
+ if "parent_author" not in comment:
136
+ comment["parent_author"] = ""
137
+ if "parent_permlink" not in comment:
138
+ comment["parent_permlink"] = ""
139
+ # Parse Amounts
140
+ hbd_amounts = [
141
+ "total_payout_value",
142
+ "max_accepted_payout",
143
+ "pending_payout_value",
144
+ "curator_payout_value",
145
+ "total_pending_payout_value",
146
+ "promoted",
147
+ ]
148
+ for p in hbd_amounts:
149
+ if p in comment and isinstance(comment.get(p), (str, list, dict)):
150
+ value = comment.get(p, "0.000 %s" % (self.blockchain.backed_token_symbol))
151
+ if (
152
+ isinstance(value, str)
153
+ and value.split(" ")[1] != self.blockchain.backed_token_symbol
154
+ ):
155
+ value = value.split(" ")[0] + " " + self.blockchain.backed_token_symbol
156
+ comment[p] = Amount(value, blockchain_instance=self.blockchain)
157
+
158
+ if "community" not in comment:
159
+ comment["community"] = ""
160
+
161
+ # turn json_metadata into python dict
162
+ meta_str = comment.get("json_metadata", "{}")
163
+ if meta_str == "{}":
164
+ comment["json_metadata"] = meta_str
165
+ if isinstance(meta_str, (str, bytes, bytearray)):
166
+ try:
167
+ comment["json_metadata"] = json.loads(meta_str)
168
+ except Exception:
169
+ comment["json_metadata"] = {}
170
+
171
+ comment["tags"] = []
172
+ if isinstance(comment["json_metadata"], dict):
173
+ if "tags" in comment["json_metadata"]:
174
+ comment["tags"] = comment["json_metadata"]["tags"]
175
+ if "community" in comment["json_metadata"]:
176
+ comment["community"] = comment["json_metadata"]["community"]
177
+
178
+ parse_int = [
179
+ "author_reputation",
180
+ "net_rshares",
181
+ ]
182
+ for p in parse_int:
183
+ if p in comment and isinstance(comment.get(p), str):
184
+ comment[p] = int(comment.get(p, "0"))
185
+
186
+ if "active_votes" in comment:
187
+ new_active_votes = []
188
+ for vote in comment["active_votes"]:
189
+ if "time" in vote and isinstance(vote.get("time"), str):
190
+ vote["time"] = parse_time(vote.get("time", "1970-01-01T00:00:00"))
191
+ parse_int = [
192
+ "rshares",
193
+ "reputation",
194
+ ]
195
+ for p in parse_int:
196
+ if p in vote and isinstance(vote.get(p), str):
197
+ try:
198
+ vote[p] = int(vote.get(p, "0"))
199
+ except ValueError:
200
+ vote[p] = int(0)
201
+ new_active_votes.append(vote)
202
+ comment["active_votes"] = new_active_votes
203
+ return comment
204
+
205
+ def refresh(self) -> None:
206
+ if not self.identifier:
207
+ return
208
+ if not self.blockchain.is_connected():
209
+ return
210
+ [author, permlink] = resolve_authorperm(str(self.identifier))
211
+ self.blockchain.rpc.set_next_node_on_empty_reply(True)
212
+ from nectarapi.exceptions import InvalidParameters
213
+
214
+ try:
215
+ if self.api == "condenser_api":
216
+ content = self.blockchain.rpc.get_content(
217
+ author,
218
+ permlink,
219
+ api="condenser_api",
220
+ )
221
+ else:
222
+ # Default to bridge.get_post; database_api does not expose get_post
223
+ content = self.blockchain.rpc.get_post(
224
+ {"author": author, "permlink": permlink, "observer": self.observer},
225
+ api="bridge",
226
+ )
227
+ if content is not None and "comments" in content:
228
+ content = content["comments"]
229
+ if isinstance(content, list) and len(content) > 0:
230
+ content = content[0]
231
+ except InvalidParameters:
232
+ raise ContentDoesNotExistsException(self.identifier)
233
+ if not content or not content["author"] or not content["permlink"]:
234
+ raise ContentDoesNotExistsException(self.identifier)
235
+ content = self._parse_json_data(content)
236
+ content["authorperm"] = construct_authorperm(content["author"], content["permlink"])
237
+ super().__init__(
238
+ content,
239
+ id_item="authorperm",
240
+ lazy=self.lazy,
241
+ full=self.full,
242
+ blockchain_instance=self.blockchain,
243
+ )
244
+
245
+ def json(self) -> Dict[str, Any]:
246
+ """
247
+ Return a JSON-serializable dict representation of the Comment.
248
+
249
+ Removes internal-only keys (e.g., "authorperm", "tags"), ensures json-compatible types, and normalizes several fields so the result can be safely serialized to JSON and consumed by external callers or APIs. Normalizations performed:
250
+ - Serializes `json_metadata` to a compact JSON string.
251
+ - Converts datetime/date values in fields like "created", "updated", "last_payout", "cashout_time", "active", and "max_cashout_time" to formatted time strings.
252
+ - Converts Amount instances in HBD-related fields (e.g., "total_payout_value", "pending_payout_value", "curator_payout_value", "promoted", etc.) to their JSON representation via Amount.json().
253
+ - Converts selected integer fields ("author_reputation", "net_rshares") and vote numeric fields ("rshares", "reputation") to strings to preserve precision across transports.
254
+ - Normalizes times and numeric fields inside each entry of "active_votes".
255
+
256
+ Returns:
257
+ dict: A JSON-safe copy of the comment data suitable for json.dumps or returning from an API.
258
+ """
259
+ output = self.copy()
260
+ if "authorperm" in output:
261
+ output.pop("authorperm")
262
+ if "json_metadata" in output:
263
+ output["json_metadata"] = json.dumps(output["json_metadata"])
264
+ if "tags" in output:
265
+ output.pop("tags")
266
+ parse_times = [
267
+ "active",
268
+ "cashout_time",
269
+ "created",
270
+ "last_payout",
271
+ "last_update",
272
+ "updated",
273
+ "max_cashout_time",
274
+ ]
275
+ for p in parse_times:
276
+ if p in output:
277
+ p_date = output.get(p, datetime(1970, 1, 1, 0, 0))
278
+ if isinstance(p_date, (datetime, date)):
279
+ output[p] = formatTimeString(p_date)
280
+ else:
281
+ output[p] = p_date
282
+ hbd_amounts = [
283
+ "total_payout_value",
284
+ "max_accepted_payout",
285
+ "pending_payout_value",
286
+ "curator_payout_value",
287
+ "total_pending_payout_value",
288
+ "promoted",
289
+ ]
290
+ for p in hbd_amounts:
291
+ if p in output and isinstance(output[p], Amount):
292
+ output[p] = output[p].json()
293
+ parse_int = [
294
+ "author_reputation",
295
+ "net_rshares",
296
+ ]
297
+ for p in parse_int:
298
+ if p in output and isinstance(output[p], int):
299
+ output[p] = str(output[p])
300
+ if "active_votes" in output:
301
+ new_active_votes = []
302
+ for vote in output["active_votes"]:
303
+ if "time" in vote:
304
+ p_date = vote.get("time", datetime(1970, 1, 1, 0, 0))
305
+ if isinstance(p_date, (datetime, date)):
306
+ vote["time"] = formatTimeString(p_date)
307
+ else:
308
+ vote["time"] = p_date
309
+ parse_int = [
310
+ "rshares",
311
+ "reputation",
312
+ ]
313
+ for p in parse_int:
314
+ if p in vote and isinstance(vote[p], int):
315
+ vote[p] = str(vote[p])
316
+ new_active_votes.append(vote)
317
+ output["active_votes"] = new_active_votes
318
+ return json.loads(str(json.dumps(output)))
319
+
320
+ def to_zero(
321
+ self, account: Union[str, Account], partial: float = 100.0, nobroadcast: bool = False
322
+ ) -> float:
323
+ """
324
+ Compute the UI downvote percent needed for `account` to reduce this post's
325
+ pending payout to approximately zero using payout-based math (reward fund
326
+ + median price + effective vests) scaled by the account's current downvoting power.
327
+
328
+ - Returns a negative UI percent (e.g., -75.0 means a 75% downvote).
329
+ - Automatically casts the downvote (use the blockchain instance's ``no_broadcast``
330
+ flag to suppress broadcasting when needed).
331
+
332
+ If the account's current 100% downvote value is insufficient, returns -100.0.
333
+ If the post has no pending payout, returns 0.0.
334
+ """
335
+ from .account import Account
336
+
337
+ # Pending payout in HBD
338
+ pending_amt = self.get(
339
+ "pending_payout_value",
340
+ Amount(0, self.blockchain.backed_token_symbol, blockchain_instance=self.blockchain),
341
+ )
342
+ pending_payout = float(Amount(pending_amt, blockchain_instance=self.blockchain))
343
+ if pending_payout <= 0:
344
+ return 0.0
345
+
346
+ # Reward fund and price
347
+ reward_fund = self.blockchain.get_reward_funds()
348
+ recent_claims = int(reward_fund["recent_claims"]) if reward_fund else 0
349
+ if recent_claims == 0:
350
+ return -100.0
351
+ reward_balance = float(
352
+ Amount(reward_fund["reward_balance"], blockchain_instance=self.blockchain)
353
+ )
354
+ median_price = self.blockchain.get_median_price()
355
+ if median_price is None:
356
+ return -100.0
357
+ HBD_per_HIVE = float(
358
+ median_price
359
+ * Amount(1, self.blockchain.token_symbol, blockchain_instance=self.blockchain)
360
+ )
361
+
362
+ # Stake and downvoting power
363
+ acct = Account(account, blockchain_instance=self.blockchain)
364
+ effective_vests = float(acct.get_effective_vesting_shares())
365
+ final_vests = effective_vests * 1e6
366
+ get_dvp = getattr(acct, "get_downvoting_power", None)
367
+ downvote_power_pct = float(get_dvp() if callable(get_dvp) else acct.get_voting_power())
368
+
369
+ # Reference 100% downvote value using provided sample formula
370
+ power = (100 * 100 / 10000.0) / 50.0 # (vote_power * vote_weight / 10000) / 50
371
+ rshares_full = power * final_vests / 10000.0
372
+ current_downvote_value_hbd = (
373
+ (rshares_full * reward_balance / recent_claims) * HBD_per_HIVE
374
+ ) / 100.0
375
+ current_downvote_value_hbd *= downvote_power_pct / 100.0
376
+ if current_downvote_value_hbd <= 0:
377
+ return -100.0
378
+
379
+ percent_needed_ui = pending_payout / current_downvote_value_hbd # fraction of a 100% vote
380
+ ui_pct = -percent_needed_ui * (partial / 100.0)
381
+ # Scale to UI percent
382
+ ui_pct_scaled = ui_pct * 100.0
383
+ if ui_pct_scaled < -100.0:
384
+ ui_pct_scaled = -100.0
385
+ if ui_pct_scaled > 0.0:
386
+ ui_pct_scaled = 0.0
387
+
388
+ if ui_pct_scaled < 0.0 and not nobroadcast:
389
+ self.downvote(abs(ui_pct_scaled), voter=account)
390
+ return ui_pct_scaled
391
+
392
+ def to_token_value(
393
+ self,
394
+ account: Union[str, Account],
395
+ hbd: bool = False,
396
+ partial: float = 100.0,
397
+ nobroadcast: bool = False,
398
+ ) -> float:
399
+ """
400
+ Compute the UI upvote percent needed for `account` so the vote contributes
401
+ approximately `hbd` HBD to payout (payout-based math).
402
+
403
+ - Returns a positive UI percent (e.g., 75.0 means 75% upvote).
404
+ - Automatically casts the upvote (use the blockchain instance's ``no_broadcast``
405
+ flag to suppress broadcasting when needed).
406
+ """
407
+ from .account import Account
408
+
409
+ # Normalize target HBD
410
+ try:
411
+ target_hbd = float(Amount(hbd, blockchain_instance=self.blockchain))
412
+ except Exception:
413
+ target_hbd = float(hbd)
414
+ if target_hbd <= 0:
415
+ return 0.0
416
+
417
+ # Reward fund and price
418
+ reward_fund = self.blockchain.get_reward_funds()
419
+ recent_claims = int(reward_fund["recent_claims"]) if reward_fund else 0
420
+ if recent_claims == 0:
421
+ return 0.0
422
+ reward_balance = float(
423
+ Amount(reward_fund["reward_balance"], blockchain_instance=self.blockchain)
424
+ )
425
+ median_price = self.blockchain.get_median_price()
426
+ if median_price is None:
427
+ return 0.0
428
+ HBD_per_HIVE = float(
429
+ median_price
430
+ * Amount(1, self.blockchain.token_symbol, blockchain_instance=self.blockchain)
431
+ )
432
+
433
+ # Stake and voting power
434
+ acct = Account(account, blockchain_instance=self.blockchain)
435
+ effective_vests = float(acct.get_effective_vesting_shares())
436
+ final_vests = effective_vests * 1e6
437
+ voting_power_pct = float(acct.get_voting_power())
438
+
439
+ # Reference 100% upvote value using provided sample formula
440
+ power = (100 * 100 / 10000.0) / 50.0
441
+ rshares_full = power * final_vests / 10000.0
442
+ current_upvote_value_hbd = (
443
+ (rshares_full * reward_balance / recent_claims) * HBD_per_HIVE
444
+ ) / 100.0
445
+ current_upvote_value_hbd *= voting_power_pct / 100.0
446
+ if current_upvote_value_hbd <= 0:
447
+ return 0.0
448
+
449
+ percent_needed_ui = target_hbd / current_upvote_value_hbd # fraction of a 100% vote
450
+ ui_pct = percent_needed_ui * (partial / 100.0)
451
+ # Scale to UI percent
452
+ ui_pct_scaled = ui_pct * 100.0
453
+ if ui_pct_scaled > 100.0:
454
+ ui_pct_scaled = 100.0
455
+ if ui_pct_scaled < 0.0:
456
+ ui_pct_scaled = 0.0
457
+
458
+ if ui_pct_scaled > 0.0 and not nobroadcast:
459
+ self.upvote(ui_pct_scaled, voter=account)
460
+ return ui_pct_scaled
461
+
462
+ @property
463
+ def id(self) -> int:
464
+ return self["id"]
465
+
466
+ @property
467
+ def author(self) -> str:
468
+ return self["author"]
469
+
470
+ @property
471
+ def permlink(self) -> str:
472
+ return self["permlink"]
473
+
474
+ @property
475
+ def authorperm(self) -> str:
476
+ return construct_authorperm(self["author"], self["permlink"])
477
+
478
+ @property
479
+ def category(self) -> str:
480
+ if "category" in self:
481
+ return self["category"]
482
+ else:
483
+ return ""
484
+
485
+ @property
486
+ def community(self):
487
+ if "community" in self:
488
+ return self["community"]
489
+ else:
490
+ return ""
491
+
492
+ @property
493
+ def community_title(self):
494
+ """The Community title property."""
495
+ if "community_title" in self:
496
+ return self["community_title"]
497
+ else:
498
+ return ""
499
+
500
+ @property
501
+ def parent_author(self):
502
+ if "parent_author" in self:
503
+ return self["parent_author"]
504
+ else:
505
+ return ""
506
+
507
+ @property
508
+ def parent_permlink(self):
509
+ if "parent_permlink" in self:
510
+ return self["parent_permlink"]
511
+ else:
512
+ return ""
513
+
514
+ @property
515
+ def depth(self):
516
+ return self["depth"]
517
+
518
+ @property
519
+ def title(self):
520
+ if "title" in self:
521
+ return self["title"]
522
+ else:
523
+ return ""
524
+
525
+ @property
526
+ def body(self):
527
+ if "body" in self:
528
+ return self["body"]
529
+ else:
530
+ return ""
531
+
532
+ @property
533
+ def json_metadata(self):
534
+ if "json_metadata" in self:
535
+ return self["json_metadata"]
536
+ else:
537
+ return {}
538
+
539
+ def is_main_post(self):
540
+ """Returns True if main post, and False if this is a comment (reply)."""
541
+ if "depth" in self:
542
+ return self["depth"] == 0
543
+ else:
544
+ return self["parent_author"] == ""
545
+
546
+ def is_comment(self):
547
+ """Returns True if post is a comment"""
548
+ if "depth" in self:
549
+ return self["depth"] > 0
550
+ else:
551
+ return self["parent_author"] != ""
552
+
553
+ @property
554
+ def reward(self):
555
+ """
556
+ Return the post's total estimated reward as an Amount.
557
+
558
+ This is the sum of `total_payout_value`, `curator_payout_value`, and `pending_payout_value`
559
+ (from the comment data). Each component is converted to an Amount using the comment's
560
+ blockchain-backed token symbol before summing.
561
+
562
+ Returns:
563
+ Amount: Total estimated reward (in the blockchain's backed token, e.g., HBD).
564
+ """
565
+ a_zero = Amount(0, self.blockchain.backed_token_symbol, blockchain_instance=self.blockchain)
566
+ author = Amount(self.get("total_payout_value", a_zero), blockchain_instance=self.blockchain)
567
+ curator = Amount(
568
+ self.get("curator_payout_value", a_zero), blockchain_instance=self.blockchain
569
+ )
570
+ pending = Amount(
571
+ self.get("pending_payout_value", a_zero), blockchain_instance=self.blockchain
572
+ )
573
+ return author + curator + pending
574
+
575
+ def is_pending(self):
576
+ """Returns if the payout is pending (the post/comment
577
+ is younger than 7 days)
578
+ """
579
+ a_zero = Amount(0, self.blockchain.backed_token_symbol, blockchain_instance=self.blockchain)
580
+ total = Amount(self.get("total_payout_value", a_zero), blockchain_instance=self.blockchain)
581
+ post_age_days = self.time_elapsed().total_seconds() / 60 / 60 / 24
582
+ return post_age_days < 7.0 and float(total) == 0
583
+
584
+ def time_elapsed(self):
585
+ """
586
+ Return the time elapsed since the post was created as a timedelta.
587
+
588
+ The difference is computed as now (UTC) minus the post's `created` timestamp (a timezone-aware datetime).
589
+ A positive timedelta indicates the post is in the past; a negative value can occur if `created` is in the future.
590
+ """
591
+ created = self["created"]
592
+ if isinstance(created, str):
593
+ created = parse_time(created)
594
+ return datetime.now(timezone.utc) - created
595
+
596
+ def is_archived(self):
597
+ """
598
+ Determine whether the post is archived (cashout window passed).
599
+ """
600
+ cashout = self.get("cashout_time")
601
+ if isinstance(cashout, str):
602
+ cashout = parse_time(cashout)
603
+ if not isinstance(cashout, datetime):
604
+ return False
605
+ return cashout <= datetime(1970, 1, 1, tzinfo=timezone.utc) or cashout < datetime.now(
606
+ timezone.utc
607
+ ) - timedelta(days=7)
608
+
609
+ def curation_penalty_compensation_hbd(self):
610
+ """
611
+ Calculate the HBD payout a post would need (after 15 minutes) to fully compensate the curation penalty for voting earlier than 15 minutes.
612
+
613
+ This refreshes the comment data, selects the reverse-auction window based on the blockchain hardfork (HF6/HF20/HF21), and computes the required payout using the post's current reward and age.
614
+
615
+ Returns:
616
+ Amount: Estimated HBD payout required to offset the early-vote curation penalty.
617
+ """
618
+ self.refresh()
619
+ # Parse hardfork version (handle strings like "1.28.0")
620
+ if isinstance(self.blockchain.hardfork, str):
621
+ hardfork_version = 25 # Use a reasonable high value for modern Hive
622
+ else:
623
+ hardfork_version = self.blockchain.hardfork
624
+ if hardfork_version >= 21:
625
+ reverse_auction_window_seconds = HIVE_REVERSE_AUCTION_WINDOW_SECONDS_HF21
626
+ elif hardfork_version >= 20:
627
+ reverse_auction_window_seconds = HIVE_REVERSE_AUCTION_WINDOW_SECONDS_HF20
628
+ else:
629
+ reverse_auction_window_seconds = HIVE_REVERSE_AUCTION_WINDOW_SECONDS_HF6
630
+ elapsed_minutes = max((self.time_elapsed()).total_seconds() / 60, 1e-6)
631
+ return self.reward * reverse_auction_window_seconds / (elapsed_minutes**2)
632
+
633
+ def estimate_curation_hbd(self, vote_value_hbd, estimated_value_hbd=None):
634
+ """
635
+ Estimate the curation reward (in HBD) for a given vote on this post.
636
+
637
+ Refreshes the post data from the chain before computing. If `estimated_value_hbd` is not provided, the current post reward is used as the estimated total post value. The returned value is an estimate of the curator's HBD payout for a vote of size `vote_value_hbd`, accounting for the current curation penalty.
638
+
639
+ Parameters:
640
+ vote_value_hbd (float): Vote value in HBD used to compute the curation share.
641
+ estimated_value_hbd (float, optional): Estimated total post value in HBD to scale the curation; defaults to the post's current reward.
642
+
643
+ Returns:
644
+ float: Estimated curation reward in HBD for the provided vote value.
645
+ """
646
+ self.refresh()
647
+ if estimated_value_hbd is None:
648
+ estimated_value_hbd = float(self.reward)
649
+ t = 1.0 - self.get_curation_penalty()
650
+ k = vote_value_hbd / (vote_value_hbd + float(self.reward))
651
+ K = (1 - math.sqrt(1 - k)) / 4 / k
652
+ return K * vote_value_hbd * t * math.sqrt(estimated_value_hbd)
653
+
654
+ def get_curation_penalty(self, vote_time=None):
655
+ """
656
+ Return the curation penalty factor for a vote at a given time.
657
+
658
+ Calculates a value in [0.0, 1.0] representing the fraction of curation rewards
659
+ that will be removed due to early voting (0.0 = no penalty, 1.0 = full penalty).
660
+ The penalty is based on the elapsed time between the post's creation and
661
+ the vote time, scaled by the Hive reverse-auction window for the node's
662
+ current hardfork (HF21, HF20, or HF6).
663
+
664
+ Parameters:
665
+ vote_time (datetime | date | str | None): Time of the vote. If None,
666
+ the current time is used. If a string is given it will be parsed
667
+ with the module's time formatter.
668
+
669
+ Returns:
670
+ float: Penalty fraction in the range [0.0, 1.0].
671
+
672
+ Raises:
673
+ ValueError: If vote_time is not None and not a datetime, date, or parseable string.
674
+ """
675
+ if vote_time is None:
676
+ elapsed_seconds = self.time_elapsed().total_seconds()
677
+ elif isinstance(vote_time, str):
678
+ elapsed_seconds = (formatTimeString(vote_time) - self["created"]).total_seconds()
679
+ elif isinstance(vote_time, (datetime, date)):
680
+ elapsed_seconds = (vote_time - self["created"]).total_seconds()
681
+ else:
682
+ raise ValueError("vote_time must be a string or a datetime")
683
+ # Parse hardfork version (handle strings like "1.28.0")
684
+ if isinstance(self.blockchain.hardfork, str):
685
+ hardfork_version = 25 # Use a reasonable high value for modern Hive
686
+ else:
687
+ hardfork_version = self.blockchain.hardfork
688
+ if hardfork_version >= 21:
689
+ reward = elapsed_seconds / HIVE_REVERSE_AUCTION_WINDOW_SECONDS_HF21
690
+ elif hardfork_version >= 20:
691
+ reward = elapsed_seconds / HIVE_REVERSE_AUCTION_WINDOW_SECONDS_HF20
692
+ else:
693
+ reward = elapsed_seconds / HIVE_REVERSE_AUCTION_WINDOW_SECONDS_HF6
694
+ if reward > 1:
695
+ reward = 1.0
696
+ return 1.0 - reward
697
+
698
+ def get_vote_with_curation(self, voter=None, raw_data=False, pending_payout_value=None):
699
+ """
700
+ Return the specified voter's vote for this comment, optionally augmented with curation data.
701
+
702
+ If `voter` is not found in the comment's votes returns None. When a vote is found:
703
+ - If `raw_data` is True or the post is not pending payout, returns the raw vote dict.
704
+ - If the post is pending and `raw_data` is False, returns the vote dict augmented with:
705
+ - `curation_reward`: the vote's curation reward (in HBD)
706
+ - `ROI`: percent return on the voter's effective voting value
707
+
708
+ Parameters:
709
+ voter (str or Account, optional): Voter name or Account. If omitted, defaults to the post author as an Account.
710
+ raw_data (bool, optional): If True, return the found vote without adding curation/ROI fields.
711
+ pending_payout_value (float or str, optional): If provided, use this HBD value instead of the current pending payout when computing curation rewards.
712
+
713
+ Returns:
714
+ dict or None: The vote dictionary (possibly augmented with `curation_reward` and `ROI`) or None if the voter has not voted.
715
+ """
716
+ specific_vote = None
717
+ if voter is None:
718
+ voter = Account(self["author"], blockchain_instance=self.blockchain)
719
+ else:
720
+ voter = Account(voter, blockchain_instance=self.blockchain)
721
+ if "active_votes" in self:
722
+ for vote in self["active_votes"]:
723
+ if voter["name"] == vote["voter"]:
724
+ specific_vote = vote
725
+ else:
726
+ active_votes = self.get_votes()
727
+ for vote in active_votes:
728
+ if voter["name"] == vote["voter"]:
729
+ specific_vote = vote
730
+ if specific_vote is not None and (raw_data or not self.is_pending()):
731
+ return specific_vote
732
+ elif specific_vote is not None:
733
+ curation_reward = self.get_curation_rewards(
734
+ pending_payout_hbd=True, pending_payout_value=pending_payout_value
735
+ )
736
+ specific_vote["curation_reward"] = curation_reward["active_votes"][voter["name"]]
737
+ specific_vote["ROI"] = (
738
+ float(curation_reward["active_votes"][voter["name"]])
739
+ / float(
740
+ voter.get_voting_value(
741
+ voting_power=None, voting_weight=specific_vote["percent"] / 100
742
+ )
743
+ )
744
+ * 100
745
+ )
746
+ return specific_vote
747
+ else:
748
+ return None
749
+
750
+ def get_beneficiaries_pct(self):
751
+ """
752
+ Return the sum of beneficiary weights as a fraction of the full payout.
753
+
754
+ If the post has a `beneficiaries` list of dicts with integer `weight` fields (0–10000 representing 0%–100%), this returns the total weight divided by 100.0 (i.e., a float in 0.0–100.0/100 range; typical values are 0.0–1.0).
755
+ """
756
+ beneficiaries = self["beneficiaries"]
757
+ weight = 0
758
+ for b in beneficiaries:
759
+ weight += b["weight"]
760
+ return weight / HIVE_100_PERCENT
761
+
762
+ def get_rewards(self):
763
+ """
764
+ Return the post's total, author, and curator payouts as Amount objects (HBD).
765
+
766
+ If the post is pending, returns an estimated total based on pending_payout_value and derives the author's share via get_author_rewards(); curator_payout is computed as the difference. For finalized posts, uses total_payout_value and curator_payout_value.
767
+
768
+ Note: beneficiary rewards (if any) are already deducted from the returned author_payout and total_payout.
769
+
770
+ Returns:
771
+ dict: {
772
+ "total_payout": Amount,
773
+ "author_payout": Amount,
774
+ "curator_payout": Amount,
775
+ }
776
+ """
777
+ if self.is_pending():
778
+ total_payout = Amount(self["pending_payout_value"], blockchain_instance=self.blockchain)
779
+ author_payout = self.get_author_rewards()["total_payout_HBD"]
780
+ curator_payout = total_payout - author_payout
781
+ else:
782
+ author_payout = Amount(self["total_payout_value"], blockchain_instance=self.blockchain)
783
+ curator_payout = Amount(
784
+ self["curator_payout_value"], blockchain_instance=self.blockchain
785
+ )
786
+ total_payout = author_payout + curator_payout
787
+ return {
788
+ "total_payout": total_payout,
789
+ "author_payout": author_payout,
790
+ "curator_payout": curator_payout,
791
+ }
792
+
793
+ def get_author_rewards(self):
794
+ """
795
+ Return the computed author-side rewards for this post.
796
+
797
+ If the post payout is not pending, returns zero HP/HBD payouts and the concrete total payout as `total_payout_HBD`. If the payout is pending, computes the author’s share after curation and beneficiaries, and—when price history and percent_hbd are available—splits that share into HBD and HP equivalents.
798
+
799
+ Returns:
800
+ dict: A dictionary with the following keys:
801
+ - pending_rewards (bool): True when the post payout is still pending.
802
+ - payout_HP (Amount or None): Estimated Hive Power payout (Amount) when pending and convertible; otherwise 0 Amount (when not pending) or None.
803
+ - payout_HBD (Amount or None): Estimated HBD payout (Amount) when pending and convertible; otherwise 0 Amount (when not pending) or None.
804
+ - total_payout_HBD (Amount): Total author-side payout expressed in HBD-equivalent units when pending, or the concrete total payout when not pending.
805
+ - total_payout (Amount, optional): Present only for pending payouts in the non-convertible branch; the author-side token amount before HBD/HP splitting.
806
+ - Note: When price/percent data is not available, `payout_HP` and `payout_HBD` will be None and only `total_payout_HBD`/`total_payout` convey the author share.
807
+
808
+ Example:
809
+ {
810
+ "pending_rewards": True,
811
+ "payout_HP": Amount(...), # HP equivalent (when convertible)
812
+ "payout_HBD": Amount(...), # HBD portion (when convertible)
813
+ "total_payout_HBD": Amount(...) # Total author share in HBD-equivalent
814
+ }
815
+ """
816
+ if not self.is_pending():
817
+ return {
818
+ "pending_rewards": False,
819
+ "payout_HP": Amount(
820
+ 0, self.blockchain.token_symbol, blockchain_instance=self.blockchain
821
+ ),
822
+ "payout_HBD": Amount(
823
+ 0, self.blockchain.backed_token_symbol, blockchain_instance=self.blockchain
824
+ ),
825
+ "total_payout_HBD": Amount(
826
+ self["total_payout_value"], blockchain_instance=self.blockchain
827
+ ),
828
+ }
829
+ author_reward_factor = 0.5
830
+ median_hist = self.blockchain.get_current_median_history()
831
+ if median_hist is not None:
832
+ median_price = Price(median_hist, blockchain_instance=self.blockchain)
833
+ beneficiaries_pct = self.get_beneficiaries_pct()
834
+ curation_tokens = self.reward * author_reward_factor
835
+ author_tokens = self.reward - curation_tokens
836
+ curation_rewards = self.get_curation_rewards()
837
+
838
+ # Parse hardfork version (handle strings like "1.28.0")
839
+ if isinstance(self.blockchain.hardfork, str):
840
+ # For version strings like "1.28.0", we only care about the major version (1)
841
+ # Since all Hive hardforks we're checking against (20, 21) are much higher
842
+ # than 1, we can safely treat any version string as being >= these versions
843
+ hardfork_version = 25 # Use a reasonable high value for modern Hive
844
+ else:
845
+ hardfork_version = self.blockchain.hardfork
846
+
847
+ if hardfork_version >= 20 and median_hist is not None:
848
+ author_tokens += median_price * curation_rewards["unclaimed_rewards"]
849
+ benefactor_tokens = author_tokens * beneficiaries_pct / HIVE_100_PERCENT
850
+ author_tokens -= benefactor_tokens
851
+
852
+ if median_hist is not None and "percent_hbd" in self:
853
+ hbd_payout = author_tokens * self["percent_hbd"] / 20000.0
854
+ hp_payout = median_price.as_base(self.blockchain.token_symbol) * (
855
+ author_tokens - hbd_payout
856
+ )
857
+ return {
858
+ "pending_rewards": True,
859
+ "payout_HP": hp_payout,
860
+ "payout_HBD": hbd_payout,
861
+ "total_payout_HBD": author_tokens,
862
+ }
863
+ else:
864
+ return {
865
+ "pending_rewards": True,
866
+ "total_payout": author_tokens,
867
+ # HBD/HP primary fields
868
+ "total_payout_HBD": author_tokens,
869
+ "payout_HBD": None,
870
+ "payout_HP": None,
871
+ }
872
+
873
+ def get_curation_rewards(self, pending_payout_hbd=False, pending_payout_value=None):
874
+ """
875
+ Calculate curation rewards for this post and distribute them across active voters.
876
+
877
+ Parameters:
878
+ pending_payout_hbd (bool): If True, compute and return rewards in HBD (do not convert to HIVE/HP). Default False.
879
+ pending_payout_value (float | str | Amount | None): Optional override for the post's pending payout value used when the post is still pending.
880
+ - If None and the post is pending, the function uses the post's stored pending_payout_value.
881
+ - Accepted types: numeric, string amount, or an Amount instance.
882
+
883
+ Returns:
884
+ dict: {
885
+ "pending_rewards": bool, # True if the post is still within the payout window (uses pending_payout_value)
886
+ "unclaimed_rewards": Amount, # Amount reserved for unclaimed curation (e.g., self-votes or early votes)
887
+ "active_votes": dict # Mapping voter_name -> Amount of curation reward allocated to that voter
888
+ }
889
+
890
+ Notes:
891
+ - The function splits the curation pool using the protocol's curator share (50% by default) and prorates per-voter claims by vote weight.
892
+ - When a current median price history is available, rewards may be converted between HBD and the chain's token (HP) according to pending_payout_hbd.
893
+ """
894
+ median_hist = self.blockchain.get_current_median_history()
895
+ if median_hist is not None:
896
+ median_price = Price(median_hist, blockchain_instance=self.blockchain)
897
+ pending_rewards = False
898
+ active_votes_list = self.get_votes()
899
+ curator_reward_factor = 0.5
900
+
901
+ if "total_vote_weight" in self:
902
+ total_vote_weight = self["total_vote_weight"]
903
+ active_votes_json_list = []
904
+ for vote in active_votes_list:
905
+ if "weight" not in vote:
906
+ vote.refresh()
907
+ active_votes_json_list.append(vote.json())
908
+ else:
909
+ active_votes_json_list.append(vote.json())
910
+
911
+ total_vote_weight = 0
912
+ for vote in active_votes_json_list:
913
+ total_vote_weight += vote["weight"]
914
+
915
+ if not self.is_pending():
916
+ if pending_payout_hbd or median_hist is None:
917
+ max_rewards = Amount(
918
+ self["curator_payout_value"], blockchain_instance=self.blockchain
919
+ )
920
+ else:
921
+ max_rewards = median_price.as_base(self.blockchain.token_symbol) * Amount(
922
+ self["curator_payout_value"], blockchain_instance=self.blockchain
923
+ )
924
+ unclaimed_rewards = Amount(
925
+ 0, self.blockchain.token_symbol, blockchain_instance=self.blockchain
926
+ )
927
+ else:
928
+ if pending_payout_value is None and "pending_payout_value" in self:
929
+ pending_payout_value = Amount(
930
+ self["pending_payout_value"], blockchain_instance=self.blockchain
931
+ )
932
+ elif pending_payout_value is None:
933
+ pending_payout_value = 0
934
+ elif isinstance(pending_payout_value, (float, int)):
935
+ pending_payout_value = Amount(
936
+ pending_payout_value,
937
+ self.blockchain.backed_token_symbol,
938
+ blockchain_instance=self.blockchain,
939
+ )
940
+ elif isinstance(pending_payout_value, str):
941
+ pending_payout_value = Amount(
942
+ pending_payout_value, blockchain_instance=self.blockchain
943
+ )
944
+ if pending_payout_hbd or median_hist is None:
945
+ max_rewards = pending_payout_value * curator_reward_factor
946
+ else:
947
+ max_rewards = median_price.as_base(self.blockchain.token_symbol) * (
948
+ pending_payout_value * curator_reward_factor
949
+ )
950
+ # max_rewards is an Amount, ensure we have a copy
951
+ if isinstance(max_rewards, Amount):
952
+ unclaimed_rewards = max_rewards.copy()
953
+ else:
954
+ # Convert to Amount if it's not already
955
+ unclaimed_rewards = Amount(max_rewards, blockchain_instance=self.blockchain)
956
+ pending_rewards = True
957
+
958
+ active_votes = {}
959
+
960
+ for vote in active_votes_json_list:
961
+ if total_vote_weight > 0:
962
+ claim = max_rewards * int(vote["weight"]) / total_vote_weight
963
+ else:
964
+ claim = 0
965
+ if claim > 0 and pending_rewards:
966
+ # Ensure claim is an Amount for subtraction
967
+ if not isinstance(claim, Amount):
968
+ claim_amount = Amount(claim, blockchain_instance=self.blockchain)
969
+ else:
970
+ claim_amount = claim
971
+ unclaimed_rewards = unclaimed_rewards - claim_amount
972
+ if claim > 0:
973
+ active_votes[vote["voter"]] = claim
974
+ else:
975
+ active_votes[vote["voter"]] = 0
976
+
977
+ return {
978
+ "pending_rewards": pending_rewards,
979
+ "unclaimed_rewards": unclaimed_rewards,
980
+ "active_votes": active_votes,
981
+ }
982
+
983
+ def get_reblogged_by(self, identifier=None):
984
+ """Shows in which blogs this post appears"""
985
+ if not identifier:
986
+ post_author = self["author"]
987
+ post_permlink = self["permlink"]
988
+ else:
989
+ [post_author, post_permlink] = resolve_authorperm(identifier)
990
+ if not self.blockchain.is_connected():
991
+ return None
992
+ self.blockchain.rpc.set_next_node_on_empty_reply(False)
993
+ try:
994
+ return self.blockchain.rpc.get_reblogged_by(post_author, post_permlink)["accounts"]
995
+ except Exception:
996
+ return self.blockchain.rpc.get_reblogged_by([post_author, post_permlink])["accounts"]
997
+
998
+ def get_replies(self, raw_data=False, identifier=None):
999
+ """Returns content replies
1000
+
1001
+ :param bool raw_data: When set to False, the replies will be returned as Comment class objects
1002
+ """
1003
+ if not identifier:
1004
+ post_author = self["author"]
1005
+ post_permlink = self["permlink"]
1006
+ else:
1007
+ [post_author, post_permlink] = resolve_authorperm(identifier)
1008
+ if not self.blockchain.is_connected():
1009
+ return None
1010
+ self.blockchain.rpc.set_next_node_on_empty_reply(False)
1011
+
1012
+ # Use bridge.get_discussion API
1013
+ content_replies = self.blockchain.rpc.get_discussion(
1014
+ {"author": post_author, "permlink": post_permlink, "observer": self.observer},
1015
+ )
1016
+
1017
+ if not content_replies:
1018
+ return []
1019
+
1020
+ # The response format is a dict with keys in 'author/permlink' format
1021
+ # We need to extract the replies by filtering out the original post
1022
+ original_key = f"{post_author}/{post_permlink}"
1023
+ replies = []
1024
+
1025
+ for key, content in content_replies.items():
1026
+ # Skip the original post
1027
+ if key == original_key:
1028
+ continue
1029
+ # Add the reply
1030
+ replies.append(content)
1031
+
1032
+ if raw_data:
1033
+ return replies
1034
+ return [Comment(c, blockchain_instance=self.blockchain) for c in replies]
1035
+
1036
+ def get_all_replies(self, parent=None):
1037
+ """Returns all content replies"""
1038
+ if parent is None:
1039
+ parent = self
1040
+ if parent["children"] > 0:
1041
+ children = parent.get_replies()
1042
+ if children is None:
1043
+ return []
1044
+ for cc in children[:]:
1045
+ children.extend(self.get_all_replies(parent=cc))
1046
+ return children
1047
+ return []
1048
+
1049
+ def get_parent(self, children=None):
1050
+ """Returns the parent post with depth == 0"""
1051
+ if children is None:
1052
+ children = self
1053
+ while children["depth"] > 0:
1054
+ children = Comment(
1055
+ construct_authorperm(children["parent_author"], children["parent_permlink"]),
1056
+ blockchain_instance=self.blockchain,
1057
+ )
1058
+ return children
1059
+
1060
+ def get_votes(self, raw_data=False):
1061
+ """Returns all votes as ActiveVotes object"""
1062
+ if raw_data and "active_votes" in self:
1063
+ return self["active_votes"]
1064
+ from .vote import ActiveVotes
1065
+
1066
+ authorperm = construct_authorperm(self["author"], self["permlink"])
1067
+ return ActiveVotes(authorperm, lazy=False, blockchain_instance=self.blockchain)
1068
+
1069
+ def upvote(
1070
+ self, weight: float = 100.0, voter: Optional[Union[str, Account]] = None
1071
+ ) -> Dict[str, Any]:
1072
+ """Upvote the post
1073
+
1074
+ :param float weight: (optional) Weight for posting (-100.0 -
1075
+ +100.0) defaults to +100.0
1076
+ :param str voter: (optional) Voting account
1077
+
1078
+ """
1079
+ if weight < 0:
1080
+ raise ValueError("Weight must be >= 0.")
1081
+ last_payout = self.get("last_payout", None)
1082
+ if last_payout is not None:
1083
+ if formatToTimeStamp(last_payout) > 0:
1084
+ raise VotingInvalidOnArchivedPost
1085
+ return self.vote(weight, account=voter)
1086
+
1087
+ def downvote(
1088
+ self, weight: float = 100.0, voter: Optional[Union[str, Account]] = None
1089
+ ) -> Dict[str, Any]:
1090
+ """Downvote the post
1091
+
1092
+ :param float weight: (optional) Weight for posting (-100.0 -
1093
+ +100.0) defaults to -100.0
1094
+ :param str voter: (optional) Voting account
1095
+
1096
+ """
1097
+ if weight < 0:
1098
+ raise ValueError("Weight must be >= 0.")
1099
+ last_payout = self.get("last_payout", None)
1100
+ if last_payout is not None:
1101
+ if formatToTimeStamp(last_payout) > 0:
1102
+ raise VotingInvalidOnArchivedPost
1103
+ return self.vote(-weight, account=voter)
1104
+
1105
+ def vote(
1106
+ self,
1107
+ weight: float,
1108
+ account: Optional[Union[str, Account]] = None,
1109
+ identifier: Optional[str] = None,
1110
+ **kwargs: Any,
1111
+ ) -> Dict[str, Any]:
1112
+ """Vote for a post
1113
+
1114
+ :param float weight: Voting weight. Range: -100.0 - +100.0.
1115
+ :param str account: (optional) Account to use for voting. If
1116
+ ``account`` is not defined, the ``default_account`` will be used
1117
+ or a ValueError will be raised
1118
+ :param str identifier: Identifier for the post to vote. Takes the
1119
+ form ``@author/permlink``.
1120
+
1121
+ """
1122
+ if not identifier:
1123
+ identifier = construct_authorperm(self["author"], self["permlink"])
1124
+
1125
+ return self.blockchain.vote(weight, identifier, account=account)
1126
+
1127
+ def edit(self, body, meta=None, replace=False):
1128
+ """Edit an existing post
1129
+
1130
+ :param str body: Body of the reply
1131
+ :param json meta: JSON meta object that can be attached to the
1132
+ post. (optional)
1133
+ :param bool replace: Instead of calculating a *diff*, replace
1134
+ the post entirely (defaults to ``False``)
1135
+
1136
+ """
1137
+ if not meta:
1138
+ meta = {}
1139
+ original_post = self
1140
+
1141
+ if replace:
1142
+ newbody = body
1143
+ else:
1144
+ newbody = make_patch(original_post["body"], body)
1145
+ if not newbody:
1146
+ log.info("No changes made! Skipping ...")
1147
+ return
1148
+
1149
+ reply_identifier = construct_authorperm(
1150
+ original_post["parent_author"], original_post["parent_permlink"]
1151
+ )
1152
+
1153
+ new_meta = {}
1154
+ if meta is not None:
1155
+ if bool(original_post["json_metadata"]):
1156
+ new_meta = original_post["json_metadata"]
1157
+ for key in meta:
1158
+ new_meta[key] = meta[key]
1159
+ else:
1160
+ new_meta = meta
1161
+
1162
+ return self.blockchain.post(
1163
+ original_post["title"],
1164
+ newbody,
1165
+ reply_identifier=reply_identifier,
1166
+ author=original_post["author"],
1167
+ permlink=original_post["permlink"],
1168
+ json_metadata=new_meta,
1169
+ )
1170
+
1171
+ def reply(self, body, title="", author="", meta=None):
1172
+ """Reply to an existing post
1173
+
1174
+ :param str body: Body of the reply
1175
+ :param str title: Title of the reply post
1176
+ :param str author: Author of reply (optional) if not provided
1177
+ ``default_user`` will be used, if present, else
1178
+ a ``ValueError`` will be raised.
1179
+ :param json meta: JSON meta object that can be attached to the
1180
+ post. (optional)
1181
+
1182
+ """
1183
+ return self.blockchain.post(
1184
+ title, body, json_metadata=meta, author=author, reply_identifier=self.identifier
1185
+ )
1186
+
1187
+ def delete(self, account=None, identifier=None):
1188
+ """
1189
+ Delete this post or comment from the blockchain.
1190
+
1191
+ If `identifier` is provided it must be an author/permlink string (e.g. "@author/permlink"); otherwise the current Comment's author and permlink are used. If `account` is not provided the method will use `blockchain.config["default_account"]` when present; otherwise a ValueError is raised.
1192
+
1193
+ Note: a post/comment can only be deleted if it has no replies and no positive rshares.
1194
+
1195
+ Parameters:
1196
+ account (str, optional): Account name to perform the deletion. If omitted, the configured default_account is used.
1197
+ identifier (str, optional): Author/permlink of the post to delete (format "@author/permlink"). Defaults to the current Comment.
1198
+
1199
+ Returns:
1200
+ dict: Result of the blockchain finalizeOp / transaction broadcast.
1201
+
1202
+ Raises:
1203
+ ValueError: If no account is provided and no default_account is configured.
1204
+ """
1205
+ if not account:
1206
+ if "default_account" in self.blockchain.config:
1207
+ account = self.blockchain.config["default_account"]
1208
+ if not account:
1209
+ raise ValueError("You need to provide an account")
1210
+ account = Account(account, blockchain_instance=self.blockchain)
1211
+ if not identifier:
1212
+ post_author = self["author"]
1213
+ post_permlink = self["permlink"]
1214
+ else:
1215
+ [post_author, post_permlink] = resolve_authorperm(identifier)
1216
+ op = operations.Delete_comment(**{"author": post_author, "permlink": post_permlink})
1217
+ return self.blockchain.finalizeOp(op, account, "posting")
1218
+
1219
+ def reblog(self, identifier=None, account=None):
1220
+ """
1221
+ Create a reblog (resteem) for the specified post.
1222
+
1223
+ Parameters:
1224
+ identifier (str, optional): Post identifier in the form "@author/permlink". If omitted, uses this Comment's identifier.
1225
+ account (str, optional): Name of the posting account to perform the reblog. If omitted, the configured `default_account` is used.
1226
+
1227
+ Returns:
1228
+ dict: Result from the blockchain custom_json operation.
1229
+
1230
+ Raises:
1231
+ ValueError: If no account is provided and no `default_account` is configured.
1232
+ """
1233
+ if not account:
1234
+ account = self.blockchain.configStorage.get("default_account")
1235
+ if not account:
1236
+ raise ValueError("You need to provide an account")
1237
+ account = Account(account, blockchain_instance=self.blockchain)
1238
+ if identifier is None:
1239
+ identifier = self.identifier
1240
+ if identifier is None:
1241
+ raise ValueError("No identifier available")
1242
+ author, permlink = resolve_authorperm(str(identifier))
1243
+ json_body = ["reblog", {"account": account["name"], "author": author, "permlink": permlink}]
1244
+ return self.blockchain.custom_json(
1245
+ id="follow", json_data=json_body, required_posting_auths=[account["name"]]
1246
+ )
1247
+
1248
+
1249
+ class RecentReplies(list):
1250
+ """Obtain a list of recent replies
1251
+
1252
+ :param str author: author
1253
+ :param bool skip_own: (optional) Skip replies of the author to him/herself.
1254
+ Default: True
1255
+ :param Blockchain blockchain_instance: Blockchain instance to use when accessing the RPC
1256
+ """
1257
+
1258
+ def __init__(
1259
+ self,
1260
+ author,
1261
+ skip_own=True,
1262
+ start_permlink="",
1263
+ limit=100,
1264
+ lazy=False,
1265
+ full=True,
1266
+ blockchain_instance=None,
1267
+ ):
1268
+ """
1269
+ Create a list of recent replies to a given account.
1270
+
1271
+ Initializes the instance as a list of Comment objects built from the account's recent "replies" feed. By default replies authored by the same account are omitted when skip_own is True. If no blockchain connection is available during construction, initialization is aborted (the constructor returns early).
1272
+
1273
+ Parameters:
1274
+ author (str): Account name whose replies to collect.
1275
+ skip_own (bool): If True, omit replies authored by `author`. Default True.
1276
+ start_permlink (str): Legacy/paging parameter; currently ignored by this implementation.
1277
+ limit (int): Maximum number of replies to collect; currently ignored (the underlying API call controls results).
1278
+ lazy (bool): If True, create Comment objects in lazy mode.
1279
+ full (bool): If True, create Comment objects with full data populated.
1280
+
1281
+ Notes:
1282
+ - The blockchain_instance parameter is used to resolve RPC access and is intentionally undocumented here as a shared service.
1283
+ - The underlying account.get_account_posts(sort="replies", raw_data=True) call provides the source data; when it returns None or the instance is not connected, construction exits early.
1284
+ """
1285
+ self.blockchain = blockchain_instance or shared_blockchain_instance()
1286
+ if not self.blockchain.is_connected():
1287
+ return None
1288
+ self.blockchain.rpc.set_next_node_on_empty_reply(True)
1289
+ account = Account(author, blockchain_instance=self.blockchain)
1290
+ replies = account.get_account_posts(sort="replies", raw_data=True)
1291
+ comments = []
1292
+ if replies is None:
1293
+ replies = []
1294
+ for post in replies:
1295
+ if skip_own and post["author"] == author:
1296
+ continue
1297
+ comments.append(
1298
+ Comment(post, lazy=lazy, full=full, blockchain_instance=self.blockchain)
1299
+ )
1300
+ super().__init__(comments)
1301
+
1302
+
1303
+ class RecentByPath(list):
1304
+ """Obtain a list of posts recent by path, does the same as RankedPosts
1305
+
1306
+ :param str path: path
1307
+ :param str tag: tag
1308
+ :param str observer: observer
1309
+ :param Blockchain blockchain_instance: Blockchain instance to use when accessing the RPC
1310
+ """
1311
+
1312
+ def __init__(
1313
+ self,
1314
+ path="trending",
1315
+ tag="",
1316
+ observer="",
1317
+ lazy=False,
1318
+ full=True,
1319
+ limit=20,
1320
+ blockchain_instance=None,
1321
+ ):
1322
+ """
1323
+ Create a RecentByPath list by fetching ranked posts for a given path/tag and initializing the list with those posts.
1324
+
1325
+ Parameters:
1326
+ path (str): Ranking category to fetch (e.g., "trending", "hot").
1327
+ tag (str): Optional tag to filter posts.
1328
+ observer (str): Observer account used for context-aware fetches (affects reward/curation visibility).
1329
+ lazy (bool): If True, create Comment objects lazily (defer full data loading).
1330
+ full (bool): If True, initialize Comment objects with full data when available.
1331
+ limit (int): Maximum number of posts to fetch.
1332
+ """
1333
+ self.blockchain = blockchain_instance or shared_blockchain_instance()
1334
+
1335
+ # Create RankedPosts with proper parameters
1336
+ ranked_posts = RankedPosts(
1337
+ sort=path,
1338
+ tag=tag,
1339
+ observer=observer,
1340
+ limit=limit,
1341
+ lazy=lazy,
1342
+ full=full,
1343
+ blockchain_instance=self.blockchain,
1344
+ )
1345
+
1346
+ super().__init__(ranked_posts)
1347
+
1348
+
1349
+ class RankedPosts(list):
1350
+ """Obtain a list of ranked posts
1351
+
1352
+ :param str sort: can be: trending, hot, created, promoted, payout, payout_comments, muted
1353
+ :param str tag: tag, when used my, the community posts of the observer are shown
1354
+ :param str observer: Observer name
1355
+ :param int limit: limits the number of returns comments
1356
+ :param str start_author: start author
1357
+ :param str start_permlink: start permlink
1358
+ :param Blockchain blockchain_instance: Blockchain instance to use when accessing the RPC
1359
+ """
1360
+
1361
+ def __init__(
1362
+ self,
1363
+ sort,
1364
+ tag="",
1365
+ observer="",
1366
+ limit=21,
1367
+ start_author="",
1368
+ start_permlink="",
1369
+ lazy=False,
1370
+ full=True,
1371
+ raw_data=False,
1372
+ blockchain_instance=None,
1373
+ ):
1374
+ """
1375
+ Initialize a RankedPosts list by fetching paginated ranked posts from the blockchain.
1376
+
1377
+ Fetches up to `limit` posts for the given `sort` and `tag` using the bridge `get_ranked_posts`
1378
+ RPC, paging with `start_author` / `start_permlink`. Results are appended to the list as raw
1379
+ post dicts when `raw_data` is True, or as Comment objects otherwise. The constructor:
1380
+ - uses `blockchain_instance` (or the shared instance) and returns early (None) if not connected;
1381
+ - pages through results with an API page size up to 100, updating `start_author`/`start_permlink`;
1382
+ - avoids repeating the last item returned by the API; and
1383
+ - on an RPC error, returns partial results if any posts were already collected, otherwise re-raises.
1384
+
1385
+ Parameters:
1386
+ sort (str): Ranking to query (e.g., "trending", "hot", "created").
1387
+ tag (str): Optional tag/category to filter by.
1388
+ observer (str): Optional observer account used by the bridge API (affects personalized results).
1389
+ limit (int): Maximum number of posts to return.
1390
+ start_author (str): Author to start paging from (inclusive/exclusive depends on the API).
1391
+ start_permlink (str): Permlink to start paging from.
1392
+ lazy (bool): If False, wrap results in Comment objects fully; if True, create Comment objects in lazy mode.
1393
+ full (bool): If True, request full Comment initialization when wrapping results.
1394
+ raw_data (bool): If True, return raw post dictionaries instead of Comment objects.
1395
+ """
1396
+ self.blockchain = blockchain_instance or shared_blockchain_instance()
1397
+ if not self.blockchain.is_connected():
1398
+ return None
1399
+ comments = []
1400
+ # Bridge API enforces a maximum page size (typically 20). Cap
1401
+ # per-request size accordingly and page until `limit` is reached.
1402
+ # Previously capped to 100 which can trigger Invalid parameters.
1403
+ api_limit = min(limit, 20)
1404
+ last_n = -1
1405
+ while len(comments) < limit and last_n != len(comments):
1406
+ last_n = len(comments)
1407
+ self.blockchain.rpc.set_next_node_on_empty_reply(False)
1408
+ try:
1409
+ posts = self.blockchain.rpc.get_ranked_posts(
1410
+ {
1411
+ "sort": sort,
1412
+ "tag": tag,
1413
+ "observer": observer,
1414
+ "limit": api_limit,
1415
+ "start_author": start_author,
1416
+ "start_permlink": start_permlink,
1417
+ },
1418
+ )
1419
+ if posts is None:
1420
+ continue
1421
+ for post in posts:
1422
+ if (
1423
+ len(comments) > 0
1424
+ and comments[-1]["author"] == post["author"]
1425
+ and comments[-1]["permlink"] == post["permlink"]
1426
+ ):
1427
+ continue
1428
+ if len(comments) >= limit:
1429
+ continue
1430
+ if raw_data:
1431
+ comments.append(post)
1432
+ else:
1433
+ comments.append(
1434
+ Comment(post, lazy=lazy, full=full, blockchain_instance=self.blockchain)
1435
+ )
1436
+ if len(comments) > 0:
1437
+ start_author = comments[-1]["author"]
1438
+ start_permlink = comments[-1]["permlink"]
1439
+ # Recompute per-request limit, capped to bridge max (20)
1440
+ remaining = limit - len(comments)
1441
+ api_limit = min(20, remaining + 1) if remaining < 20 else 20
1442
+ except Exception as e:
1443
+ # If we get an error but have some posts, return what we have
1444
+ if len(comments) > 0:
1445
+ logging.warning(f"Error in RankedPosts: {str(e)}. Returning partial results.")
1446
+ break
1447
+ # Otherwise, re-raise the exception
1448
+ raise
1449
+ super().__init__(comments)
1450
+
1451
+
1452
+ class AccountPosts(list):
1453
+ """Obtain a list of account related posts
1454
+
1455
+ :param str sort: can be: comments, posts, blog, replies, feed
1456
+ :param str account: Account name
1457
+ :param str observer: Observer name
1458
+ :param int limit: limits the number of returns comments
1459
+ :param str start_author: start author
1460
+ :param str start_permlink: start permlink
1461
+ :param Blockchain blockchain_instance: Blockchain instance to use when accessing the RPC
1462
+ """
1463
+
1464
+ def __init__(
1465
+ self,
1466
+ sort,
1467
+ account,
1468
+ observer="",
1469
+ limit=20,
1470
+ start_author="",
1471
+ start_permlink="",
1472
+ lazy=False,
1473
+ full=True,
1474
+ raw_data=False,
1475
+ blockchain_instance=None,
1476
+ ):
1477
+ """
1478
+ Initialize an AccountPosts list by fetching posts for a given account (paginated).
1479
+
1480
+ This constructor populates the list with posts returned by the bridge.get_account_posts RPC call,
1481
+ respecting paging (start_author/start_permlink) and the requested limit. Each item is either the
1482
+ raw post dict (when raw_data=True) or a Comment object constructed with the same blockchain instance.
1483
+
1484
+ Parameters:
1485
+ sort (str): The post list type to fetch (e.g., "blog", "comments", "replies", "feed").
1486
+ account (str): Account name whose posts are requested.
1487
+ observer (str): Optional observer account name used by the API (affects visibility/context).
1488
+ limit (int): Maximum number of posts to collect.
1489
+ start_author (str): Author to start paging from (inclusive/exclusive depends on API).
1490
+ start_permlink (str): Permlink to start paging from.
1491
+ lazy (bool): If False, Comment objects are fully loaded; if True, they are initialized lazily.
1492
+ full (bool): If True, Comment objects include full data; otherwise minimal fields.
1493
+ raw_data (bool): If True, return raw post dicts instead of Comment objects.
1494
+
1495
+ Behavior notes:
1496
+ - If the blockchain instance is not connected, initialization returns early (no posts are fetched).
1497
+ - On an RPC error, if some posts have already been collected, the constructor returns those partial results;
1498
+ if no posts were collected, the exception is propagated.
1499
+ """
1500
+ self.blockchain = blockchain_instance or shared_blockchain_instance()
1501
+ if not self.blockchain.is_connected():
1502
+ return None
1503
+ comments = []
1504
+ # Bridge API typically restricts page size to 20; cap per-request size
1505
+ # and page as needed until overall `limit` is satisfied.
1506
+ api_limit = min(limit, 20)
1507
+ last_n = -1
1508
+ while len(comments) < limit and last_n != len(comments):
1509
+ last_n = len(comments)
1510
+ self.blockchain.rpc.set_next_node_on_empty_reply(False)
1511
+ try:
1512
+ posts = self.blockchain.rpc.get_account_posts(
1513
+ {
1514
+ "sort": sort,
1515
+ "account": account,
1516
+ "observer": observer,
1517
+ "limit": api_limit,
1518
+ "start_author": start_author,
1519
+ "start_permlink": start_permlink,
1520
+ },
1521
+ )
1522
+ if posts is None:
1523
+ continue
1524
+ for post in posts:
1525
+ if (
1526
+ len(comments) > 0
1527
+ and comments[-1]["author"] == post["author"]
1528
+ and comments[-1]["permlink"] == post["permlink"]
1529
+ ):
1530
+ continue
1531
+ if len(comments) >= limit:
1532
+ continue
1533
+ if raw_data:
1534
+ comments.append(post)
1535
+ else:
1536
+ comments.append(
1537
+ Comment(post, lazy=lazy, full=full, blockchain_instance=self.blockchain)
1538
+ )
1539
+ if len(comments) > 0:
1540
+ start_author = comments[-1]["author"]
1541
+ start_permlink = comments[-1]["permlink"]
1542
+ # Recompute per-request limit for next page, still capped at 20
1543
+ remaining = limit - len(comments)
1544
+ api_limit = min(20, remaining + 1) if remaining < 20 else 20
1545
+ except Exception as e:
1546
+ # If we get an error but have some posts, return what we have
1547
+ if len(comments) > 0:
1548
+ logging.warning(f"Error in AccountPosts: {str(e)}. Returning partial results.")
1549
+ break
1550
+ # Otherwise, re-raise the exception
1551
+ raise
1552
+ super().__init__(comments)