pyXcom 1.2.0__tar.gz → 1.2.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.
Files changed (110) hide show
  1. {pyxcom-1.2.0 → pyxcom-1.2.1}/CHANGELOG.md +11 -0
  2. {pyxcom-1.2.0 → pyxcom-1.2.1}/CITATION.cff +2 -2
  3. {pyxcom-1.2.0 → pyxcom-1.2.1}/PKG-INFO +2 -2
  4. {pyxcom-1.2.0 → pyxcom-1.2.1}/README.md +1 -1
  5. {pyxcom-1.2.0 → pyxcom-1.2.1}/docs/api.md +23 -1
  6. {pyxcom-1.2.0 → pyxcom-1.2.1}/docs/pool.md +1 -1
  7. {pyxcom-1.2.0 → pyxcom-1.2.1}/pyproject.toml +1 -1
  8. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/__init__.py +1 -1
  9. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/cli.py +2 -0
  10. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/client.py +6 -1
  11. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/comments.py +106 -2
  12. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/pool.py +1 -0
  13. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/tables.py +6 -0
  14. pyxcom-1.2.1/tests/test_comment_stalls.py +258 -0
  15. {pyxcom-1.2.0 → pyxcom-1.2.1}/.gitignore +0 -0
  16. {pyxcom-1.2.0 → pyxcom-1.2.1}/LICENSE +0 -0
  17. {pyxcom-1.2.0 → pyxcom-1.2.1}/NOTICE +0 -0
  18. {pyxcom-1.2.0 → pyxcom-1.2.1}/benchmarks/LIVE_RESULTS.md +0 -0
  19. {pyxcom-1.2.0 → pyxcom-1.2.1}/benchmarks/README.md +0 -0
  20. {pyxcom-1.2.0 → pyxcom-1.2.1}/benchmarks/REPLAY_RESULTS.md +0 -0
  21. {pyxcom-1.2.0 → pyxcom-1.2.1}/benchmarks/STORAGE_RESULTS.md +0 -0
  22. {pyxcom-1.2.0 → pyxcom-1.2.1}/benchmarks/common.py +0 -0
  23. {pyxcom-1.2.0 → pyxcom-1.2.1}/benchmarks/environment-lock.txt +0 -0
  24. {pyxcom-1.2.0 → pyxcom-1.2.1}/benchmarks/live_arm.py +0 -0
  25. {pyxcom-1.2.0 → pyxcom-1.2.1}/benchmarks/live_compare.py +0 -0
  26. {pyxcom-1.2.0 → pyxcom-1.2.1}/benchmarks/live_pool.py +0 -0
  27. {pyxcom-1.2.0 → pyxcom-1.2.1}/benchmarks/metric-batch-results.json +0 -0
  28. {pyxcom-1.2.0 → pyxcom-1.2.1}/benchmarks/metric_batch.py +0 -0
  29. {pyxcom-1.2.0 → pyxcom-1.2.1}/benchmarks/normalize.py +0 -0
  30. {pyxcom-1.2.0 → pyxcom-1.2.1}/benchmarks/pool_summary.py +0 -0
  31. {pyxcom-1.2.0 → pyxcom-1.2.1}/benchmarks/replay.py +0 -0
  32. {pyxcom-1.2.0 → pyxcom-1.2.1}/benchmarks/requirements.txt +0 -0
  33. {pyxcom-1.2.0 → pyxcom-1.2.1}/docs/datasets.md +0 -0
  34. {pyxcom-1.2.0 → pyxcom-1.2.1}/docs/discovery-content.md +0 -0
  35. {pyxcom-1.2.0 → pyxcom-1.2.1}/docs/profile-observations.md +0 -0
  36. {pyxcom-1.2.0 → pyxcom-1.2.1}/docs/publishing.md +0 -0
  37. {pyxcom-1.2.0 → pyxcom-1.2.1}/docs/recovery.md +0 -0
  38. {pyxcom-1.2.0 → pyxcom-1.2.1}/docs/release-1.0-plan.md +0 -0
  39. {pyxcom-1.2.0 → pyxcom-1.2.1}/docs/release-1.0-validation.md +0 -0
  40. {pyxcom-1.2.0 → pyxcom-1.2.1}/docs/stability-plan.md +0 -0
  41. {pyxcom-1.2.0 → pyxcom-1.2.1}/docs/stability.md +0 -0
  42. {pyxcom-1.2.0 → pyxcom-1.2.1}/docs/timeline-pagination-plan.md +0 -0
  43. {pyxcom-1.2.0 → pyxcom-1.2.1}/docs/timeline-pagination.md +0 -0
  44. {pyxcom-1.2.0 → pyxcom-1.2.1}/examples/basic.py +0 -0
  45. {pyxcom-1.2.0 → pyxcom-1.2.1}/examples/research_networks.py +0 -0
  46. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/_persistence.py +0 -0
  47. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/_pool_locks.py +0 -0
  48. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/assets.py +0 -0
  49. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/auth.py +0 -0
  50. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/batch.py +0 -0
  51. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/datasets.py +0 -0
  52. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/discovery.py +0 -0
  53. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/errors.py +0 -0
  54. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/integrity.py +0 -0
  55. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/layout.py +0 -0
  56. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/models.py +0 -0
  57. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/networks.py +0 -0
  58. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/parse.py +0 -0
  59. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/profiles.py +0 -0
  60. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/provenance.py +0 -0
  61. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/recovery.py +0 -0
  62. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/schema.py +0 -0
  63. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/search.py +0 -0
  64. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/storage.py +0 -0
  65. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/transport.py +0 -0
  66. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/validate.py +0 -0
  67. {pyxcom-1.2.0 → pyxcom-1.2.1}/src/pyxcom/validate_tables.py +0 -0
  68. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_api_interruption.py +0 -0
  69. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_assets.py +0 -0
  70. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_auth.py +0 -0
  71. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_batch_report.py +0 -0
  72. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_benchmark_harness.py +0 -0
  73. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_benchmark_replay.py +0 -0
  74. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_capability_cli.py +0 -0
  75. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_capability_transport.py +0 -0
  76. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_comment_transport.py +0 -0
  77. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_comments.py +0 -0
  78. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_datasets.py +0 -0
  79. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_direct_search.py +0 -0
  80. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_discovery.py +0 -0
  81. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_discovery_content.py +0 -0
  82. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_discovery_pagination.py +0 -0
  83. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_integrity.py +0 -0
  84. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_layout.py +0 -0
  85. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_metric_batch.py +0 -0
  86. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_networks.py +0 -0
  87. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_observations.py +0 -0
  88. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_parsing.py +0 -0
  89. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_persistence.py +0 -0
  90. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_pool.py +0 -0
  91. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_pool_cli.py +0 -0
  92. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_pool_locks.py +0 -0
  93. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_pool_provenance.py +0 -0
  94. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_profile_batch.py +0 -0
  95. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_profile_history.py +0 -0
  96. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_project_metadata.py +0 -0
  97. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_public_api.py +0 -0
  98. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_recovery.py +0 -0
  99. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_recovery_cli.py +0 -0
  100. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_relational_integration.py +0 -0
  101. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_request_control.py +0 -0
  102. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_schema.py +0 -0
  103. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_search_scope.py +0 -0
  104. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_search_storage.py +0 -0
  105. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_session_identity.py +0 -0
  106. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_tables.py +0 -0
  107. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_timeline_pagination.py +0 -0
  108. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_transport.py +0 -0
  109. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_validate_date_scope.py +0 -0
  110. {pyxcom-1.2.0 → pyxcom-1.2.1}/tests/test_validation_shapes.py +0 -0
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.1 — 2026-10-13
4
+
5
+ ### Fixed
6
+
7
+ - Retain comment cursors paused by the empty-page guard and support explicit
8
+ `retry_stalled=True` through the saver, CLI and pool resume.
9
+ - Recover older discarded cursors only from hash-verified archives bound to the
10
+ last committed focal/cursor request, including after an ordinary resume.
11
+ - Keep historical pagination warnings while reporting still-stalled and
12
+ unrecoverable branches separately; bounded retries may remain partial.
13
+
3
14
  ## 1.2.0 — 2026-10-12
4
15
 
5
16
  ### Added
@@ -6,8 +6,8 @@ authors:
6
6
  - family-names: Wang
7
7
  given-names: Haocheng
8
8
  affiliation: Communication University of China
9
- version: 1.2.0
10
- date-released: 2026-10-12
9
+ version: 1.2.1
10
+ date-released: 2026-10-13
11
11
  license: Apache-2.0
12
12
  repository-code: "https://github.com/haochengw372-hash/pyXcom"
13
13
  url: "https://github.com/haochengw372-hash/pyXcom"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyXcom
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: Collect public X posts, search results, conversations, reposts, and observed follow networks with your browser session
5
5
  Project-URL: Homepage, https://github.com/haochengw372-hash/pyXcom
6
6
  Project-URL: Repository, https://github.com/haochengw372-hash/pyXcom
@@ -411,7 +411,7 @@ These are offline development checks. They do not collect new X data.
411
411
 
412
412
  Created by **Haocheng Wang, Communication University of China**.
413
413
 
414
- [PykTok](https://github.com/dfreelon/pyktok) inspired the approachable get/save naming and task-by-task examples. pyXcom is an independent implementation and does not depend on PykTok or twikit.
414
+ [PykTok](https://github.com/dfreelon/pyktok) inspired the approachable get/save naming and task-by-task examples.
415
415
 
416
416
  ## Citation
417
417
 
@@ -392,7 +392,7 @@ These are offline development checks. They do not collect new X data.
392
392
 
393
393
  Created by **Haocheng Wang, Communication University of China**.
394
394
 
395
- [PykTok](https://github.com/dfreelon/pyktok) inspired the approachable get/save naming and task-by-task examples. pyXcom is an independent implementation and does not depend on PykTok or twikit.
395
+ [PykTok](https://github.com/dfreelon/pyktok) inspired the approachable get/save naming and task-by-task examples.
396
396
 
397
397
  ## Citation
398
398
 
@@ -1,4 +1,4 @@
1
- # Public API — pyXcom 1.2.0
1
+ # Public API — pyXcom 1.2.1
2
2
 
3
3
  The native discovery and network methods have passed offline tests and bounded authenticated live endpoint tests. These tests verify the exercised acquisition paths, not complete historical or network coverage.
4
4
 
@@ -81,6 +81,28 @@ Traversal depth is relative to the seed (direct replies=1), recorded as `seed_po
81
81
 
82
82
  For offline validation of `post_comments`, UTC date scope applies to analysis descendants, while the seed and explicitly marked context can lie outside it. These retained records still participate in identifier, relationship and artifact-integrity checks. The exemption does not apply to other collection types; a descendant missing `observation_role` is treated as analysis for this date check.
83
83
 
84
+ ### Retry a paused comment branch
85
+
86
+ `save_post_comments(..., retry_stalled=True)` explicitly requeues cursors saved
87
+ by the empty-page guard. Ordinary resume keeps these branches paused. Repeated
88
+ cursor warnings and unresolved ancestors are still reported as partial.
89
+
90
+ For older collections, an empty-page cursor is recovered only from a hash-checked
91
+ raw response whose focal ID and request cursor match the last committed request.
92
+ Missing or mismatched evidence remains unresolved; no cursor or tree depth is guessed.
93
+
94
+ ```python
95
+ result = client.save_post_comments(
96
+ post_id, output_dir, max_depth=3, max_comments=100,
97
+ max_pages=2, retry_stalled=True,
98
+ )
99
+ ```
100
+
101
+ The manifest distinguishes queued requests, stalled requests, active warnings,
102
+ and historical warnings. A retry can still return a partial result when the
103
+ platform keeps returning empty pages. CLI: `pyxcom post-comments ID --output-dir
104
+ DIR --retry-stalled`. Pool: `resume_job(job_id, retry_stalled=True)`.
105
+
84
106
  ## Keyword search
85
107
 
86
108
  ```python
@@ -83,7 +83,7 @@ X's quota implementation. Requests already in flight cannot be recalled.
83
83
 
84
84
  Reopen the same `state_dir` with the same login IDs and call `run` again. Re-adding
85
85
  an identical job is harmless; changing its query, scope, or observer is rejected.
86
- Use `resume_job(job_id)` for a paused job. Explicit discovery-stall retries use
86
+ Use `resume_job(job_id)` for a paused job. Explicit discovery or comment empty-page retries use
87
87
  `resume_job(job_id, retry_stalled=True)` where the underlying saver supports it.
88
88
 
89
89
  Adopting existing output requires both a pinned `login_id` and
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pyXcom"
7
- version = "1.2.0"
7
+ version = "1.2.1"
8
8
  description = "Collect public X posts, search results, conversations, reposts, and observed follow networks with your browser session"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -50,4 +50,4 @@ __all__ = [
50
50
  "validate_collection",
51
51
  "validate_network_collection",
52
52
  ]
53
- __version__ = "1.2.0"
53
+ __version__ = "1.2.1"
@@ -251,6 +251,7 @@ def build_parser() -> argparse.ArgumentParser:
251
251
  help="Collect replies below a post or reply, including nested replies",
252
252
  )
253
253
  comments.add_argument("post_id_or_url")
254
+ comments.add_argument("--retry-stalled", action="store_true", help="Explicitly retry saved comment cursors paused by empty-page guard")
254
255
  comments.add_argument("--max-depth", type=_depth, default=2)
255
256
  comments.add_argument("--max-comments", type=_budget, default=100)
256
257
  comments.add_argument("--max-pages", type=_budget, default=20)
@@ -576,6 +577,7 @@ def main(argv: list[str] | None = None) -> int:
576
577
  max_pages=args.max_pages,
577
578
  since=args.since,
578
579
  until=args.until,
580
+ **({"retry_stalled": True} if args.retry_stalled else {}),
579
581
  )
580
582
  print(json.dumps(result.to_dict(), ensure_ascii=False))
581
583
  elif args.command in ("search-query", "post-quotes", "user-reposts"):
@@ -968,9 +968,10 @@ class XClient(DiscoveryMixin, NetworkMixin):
968
968
  max_pages: int | None = 20,
969
969
  since: str | None = None,
970
970
  until: str | None = None,
971
+ retry_stalled: bool = False,
971
972
  ) -> CollectionResult:
972
973
  """Save a root post, its reply tree and observed author profiles; resume safely."""
973
- from .comments import CommentTraversal
974
+ from .comments import CommentTraversal, restore_archived_stalls
974
975
 
975
976
  root_id = _post_id(post_id_or_url)
976
977
  # Validate bounds before creating or migrating output.
@@ -982,6 +983,7 @@ class XClient(DiscoveryMixin, NetworkMixin):
982
983
  max_pages=max_pages,
983
984
  since=since,
984
985
  until=until,
986
+ retry_stalled=retry_stalled,
985
987
  )
986
988
  store = PostStore(
987
989
  output_dir,
@@ -994,6 +996,8 @@ class XClient(DiscoveryMixin, NetworkMixin):
994
996
  )
995
997
  if store.complete:
996
998
  return store.finish(complete=True, reason=store.state["reason"])
999
+ if retry_stalled:
1000
+ restore_archived_stalls(store)
997
1001
  traversal = CommentTraversal(
998
1002
  self,
999
1003
  root_id,
@@ -1004,6 +1008,7 @@ class XClient(DiscoveryMixin, NetworkMixin):
1004
1008
  until=until,
1005
1009
  records=store._posts,
1006
1010
  state=store.state.setdefault("conversation", {}),
1011
+ retry_stalled=retry_stalled,
1007
1012
  )
1008
1013
 
1009
1014
  def save_profiles():
@@ -1,11 +1,14 @@
1
1
  """Bounded, resumable traversal of public replies to an X post or reply seed."""
2
2
 
3
3
  import time
4
+ import json
5
+ import hashlib
4
6
  from dataclasses import replace
5
7
  from datetime import date
6
8
  from typing import TYPE_CHECKING, Iterator
7
9
 
8
- from .errors import ParseError, RateLimitError
10
+ from .errors import IntegrityError, ParseError, RateLimitError
11
+ from ._persistence import read_jsonl
9
12
  from .models import Post
10
13
  from .parse import _walk, parse_post, parse_profile
11
14
  from .transport import now_utc
@@ -80,6 +83,71 @@ def parse_conversation(data: dict) -> tuple[list[Post], list[str], dict]:
80
83
  return list(posts.values()), list(dict.fromkeys(cursors)), profiles
81
84
 
82
85
 
86
+ def restore_archived_stalls(store) -> None:
87
+ """Recover legacy dropped frontiers from exact, verified response artifacts.
88
+
89
+ Called only for explicit retry. Missing evidence remains an unresolved stop;
90
+ no cursor is guessed and no existing post or raw artifact is rewritten.
91
+ """
92
+ state = store.state.get("conversation", {})
93
+ if (
94
+ state.get("stalled_queue")
95
+ or state.get("legacy_stall_recovery") is not None
96
+ or "empty_page_limit" not in state.get("pagination_warnings", [])
97
+ ):
98
+ return
99
+ affected = {
100
+ focal for focal, count in state.get("empty_pages", {}).items() if count >= 2
101
+ }
102
+ latest = {}
103
+ last_visited = {task[0]: task for task in state.get("visited", [])}
104
+ log_path = store.output_dir / ".pyxcom" / "collection_log.jsonl"
105
+ for record in read_jsonl(log_path) if log_path.exists() else ():
106
+ if record.get("operation") == "TweetDetail":
107
+ focal = record.get("variables", {}).get("focalTweetId")
108
+ request_cursor = record.get("variables", {}).get("cursor")
109
+ if focal in affected and last_visited.get(focal) == [focal, request_cursor]:
110
+ latest[focal] = record
111
+ recovered: list[dict] = []
112
+ unresolved: list[str] = []
113
+ for focal in sorted(affected):
114
+ archived = latest.get(focal)
115
+ if (
116
+ not archived
117
+ or not archived.get("raw_path")
118
+ or not archived.get("raw_sha256")
119
+ ):
120
+ unresolved.append(focal)
121
+ continue
122
+ path = (store.output_dir / archived["raw_path"]).resolve()
123
+ if not path.is_relative_to((store.output_dir / ".pyxcom" / "raw").resolve()):
124
+ raise IntegrityError(
125
+ "Comment retry archive must remain inside the raw directory"
126
+ )
127
+ raw = path.read_bytes()
128
+ if hashlib.sha256(raw).hexdigest() != archived["raw_sha256"]:
129
+ raise IntegrityError("Comment retry archive changed")
130
+ _, cursors, _ = parse_conversation(json.loads(raw))
131
+ tasks = [
132
+ [focal, cursor]
133
+ for cursor in cursors
134
+ if [focal, cursor] not in state.get("visited", [])
135
+ and [focal, cursor] not in state.get("queue", [])
136
+ ]
137
+ if not tasks:
138
+ unresolved.append(focal)
139
+ recovered.extend(
140
+ {"focal_id": task[0], "cursor": task[1], "reason": "empty_page_limit"}
141
+ for task in tasks
142
+ )
143
+ state["stalled_queue"] = recovered
144
+ state["unrecoverable_stalled_focals"] = unresolved
145
+ state["legacy_stall_recovery"] = {
146
+ "restored": len(recovered),
147
+ "unresolved": len(unresolved),
148
+ }
149
+
150
+
83
151
  class CommentTraversal:
84
152
  def __init__(
85
153
  self,
@@ -93,7 +161,10 @@ class CommentTraversal:
93
161
  state: dict | None = None,
94
162
  since: str | None = None,
95
163
  until: str | None = None,
164
+ retry_stalled: bool = False,
96
165
  ):
166
+ if not isinstance(retry_stalled, bool):
167
+ raise ValueError("retry_stalled must be boolean")
97
168
  if max_depth is None:
98
169
  raise ValueError("max_depth must be a positive integer")
99
170
  for name, value in (
@@ -138,6 +209,28 @@ class CommentTraversal:
138
209
  ],
139
210
  )
140
211
  self.state.setdefault("excluded_ids", [])
212
+ self.state.setdefault("stalled_queue", [])
213
+ self.state.setdefault("pagination_warning_history", [])
214
+ if retry_stalled and self.state["stalled_queue"]:
215
+ for task in self.state["stalled_queue"]:
216
+ if task["reason"] != "empty_page_limit":
217
+ continue
218
+ follow = [task["focal_id"], task["cursor"]]
219
+ if follow not in self.state["queue"]:
220
+ self.state["queue"].append(follow)
221
+ self.state["empty_pages"][task["focal_id"]] = 0
222
+ self.state["pagination_warning_history"].append(task["reason"])
223
+ self.state["stalled_queue"] = [
224
+ task
225
+ for task in self.state["stalled_queue"]
226
+ if task["reason"] != "empty_page_limit"
227
+ ]
228
+ if not self.state.get("unrecoverable_stalled_focals"):
229
+ self.state["pagination_warnings"] = [
230
+ reason
231
+ for reason in self.state["pagination_warnings"]
232
+ if reason != "empty_page_limit"
233
+ ]
141
234
  self.latest_payload: dict | None = None
142
235
  self.latest_request: dict | None = None
143
236
  self.latest_response_number = 0
@@ -252,7 +345,11 @@ class CommentTraversal:
252
345
  if not self.state["queue"]:
253
346
  unresolved = len(self.state["pending"])
254
347
  self.state["unresolved_count"] = unresolved
255
- self.complete = not unresolved and not self.state["pagination_warnings"]
348
+ self.complete = (
349
+ not unresolved
350
+ and not self.state["pagination_warnings"]
351
+ and not self.state["stalled_queue"]
352
+ )
256
353
  self.reason = (
257
354
  "visible_source_end" if self.complete else "partial_conversation"
258
355
  )
@@ -300,6 +397,13 @@ class CommentTraversal:
300
397
  self.state["pagination_warnings"].append("repeated_cursor")
301
398
  elif self.state["empty_pages"][focal] >= 2:
302
399
  self.state["pagination_warnings"].append("empty_page_limit")
400
+ paused = {
401
+ "focal_id": focal,
402
+ "cursor": next_cursor,
403
+ "reason": "empty_page_limit",
404
+ }
405
+ if paused not in self.state["stalled_queue"]:
406
+ self.state["stalled_queue"].append(paused)
303
407
  elif follow not in self.state["queue"]:
304
408
  self.state["queue"].append(follow)
305
409
  self.pages_fetched += 1
@@ -424,6 +424,7 @@ class XClientPool:
424
424
  "search_query",
425
425
  "post_quotes",
426
426
  "user_reposts",
427
+ "post_comments",
427
428
  }:
428
429
  raise ValueError("This collector does not support retry_stalled")
429
430
  job.update(status="pending", retry_stalled=retry_stalled)
@@ -184,6 +184,7 @@ def _depths(records: dict[str, Post]) -> dict[str, tuple[int | None, str]]:
184
184
  def export_tables(output_dir: str | Path) -> dict:
185
185
  """Rebuild verified public tables under an exclusive collection scope lock."""
186
186
  from .provenance import collection_write_guard
187
+
187
188
  with collection_write_guard(output_dir):
188
189
  return _export_tables_verified(output_dir)
189
190
 
@@ -452,6 +453,11 @@ def _export_tables_unchecked(output_dir: str | Path) -> dict:
452
453
  manifest["comment_collection"] = {
453
454
  "max_depth": manifest["query"]["max_depth"],
454
455
  "pending_requests": len(state.get("queue", [])),
456
+ "stalled_requests": len(state.get("stalled_queue", [])),
457
+ "unrecoverable_stalled_focals": len(
458
+ state.get("unrecoverable_stalled_focals", [])
459
+ ),
460
+ "pagination_warning_history": state.get("pagination_warning_history", []),
455
461
  "unresolved_or_deferred_records": len(state.get("pending", {})),
456
462
  "pagination_warnings": sorted(set(state.get("pagination_warnings", []))),
457
463
  "depth_counts": {
@@ -0,0 +1,258 @@
1
+ import json
2
+ import tempfile
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ from test_comments import ROOT, A, B, C, client, page, tweet, traversal
7
+ from pyxcom.storage import PostStore
8
+
9
+
10
+ class CommentStallTests(unittest.TestCase):
11
+ def stalled(self):
12
+ c = client(
13
+ page(tweet(A, ROOT), cursor="c1"), page(cursor="c2"), page(cursor="c3")
14
+ )
15
+ t = traversal(c, max_depth=3)
16
+ list(t.pages())
17
+ return c, t
18
+
19
+ def test_stalled_cursor_is_retained_and_default_resume_does_not_retry(self):
20
+ c, t = self.stalled()
21
+ self.assertEqual(
22
+ t.state["stalled_queue"],
23
+ [{"focal_id": ROOT, "cursor": "c3", "reason": "empty_page_limit"}],
24
+ )
25
+ c._x.conversation_page.reset_mock()
26
+ resumed = traversal(c, records=t.records, state=t.state, max_depth=3)
27
+ list(resumed.pages())
28
+ c._x.conversation_page.assert_not_called()
29
+ self.assertFalse(resumed.complete)
30
+
31
+ def test_explicit_retry_resumes_same_cursor_and_resolves_nested_records(self):
32
+ c, t = self.stalled()
33
+ c._x.conversation_page.reset_mock()
34
+ c._x.conversation_page.side_effect = [page(tweet(B, A), tweet(C, B))]
35
+ resumed = traversal(
36
+ c, records=t.records, state=t.state, max_depth=3, retry_stalled=True
37
+ )
38
+ list(resumed.pages())
39
+ c._x.conversation_page.assert_called_once_with(ROOT, cursor="c3")
40
+ self.assertEqual(set(resumed.records), {ROOT, A, B, C})
41
+ self.assertEqual(resumed.state["depths"][C], 3)
42
+ self.assertTrue(resumed.complete)
43
+ self.assertEqual(resumed.state["stalled_queue"], [])
44
+ self.assertIn("empty_page_limit", resumed.state["pagination_warning_history"])
45
+
46
+ def test_retry_that_stalls_again_retains_new_cursor_and_does_not_loop(self):
47
+ c, t = self.stalled()
48
+ c._x.conversation_page.reset_mock()
49
+ c._x.conversation_page.side_effect = [page(cursor="c4"), page(cursor="c5")]
50
+ resumed = traversal(
51
+ c, records=t.records, state=t.state, max_depth=3, retry_stalled=True
52
+ )
53
+ list(resumed.pages())
54
+ self.assertEqual(c._x.conversation_page.call_count, 2)
55
+ self.assertFalse(resumed.complete)
56
+ self.assertEqual(resumed.state["stalled_queue"][0]["cursor"], "c5")
57
+
58
+ def test_old_saved_empty_page_stop_can_resume_from_archived_exact_cursor(self):
59
+ with tempfile.TemporaryDirectory() as folder:
60
+ c = client(
61
+ page(tweet(A, ROOT), cursor="c1"), page(cursor="c2"), page(cursor="c3")
62
+ )
63
+ c.save_post_comments(
64
+ ROOT, folder, max_depth=3, max_comments=100, max_pages=5
65
+ )
66
+ store = PostStore(
67
+ folder,
68
+ query={"kind": "post_comments", "root_post_id": ROOT, "max_depth": 3},
69
+ )
70
+ store.state["conversation"].pop("stalled_queue", None)
71
+ store.finish(complete=False, reason="partial_conversation")
72
+ original_bytes = (Path(folder) / ".pyxcom/posts.jsonl").read_bytes()
73
+ c._x.conversation_page.reset_mock()
74
+ c._x.conversation_page.side_effect = [page(tweet(B, A))]
75
+ result = c.save_post_comments(
76
+ ROOT, folder, max_depth=3, max_pages=1, retry_stalled=True
77
+ )
78
+ c._x.conversation_page.assert_called_once_with(ROOT, cursor="c3")
79
+ self.assertEqual(result.post_count, 3)
80
+ self.assertTrue(
81
+ (Path(folder) / ".pyxcom/posts.jsonl")
82
+ .read_bytes()
83
+ .startswith(original_bytes)
84
+ )
85
+ manifest = json.loads((Path(folder) / "manifest.json").read_text())
86
+ self.assertEqual(manifest["comment_collection"]["stalled_requests"], 0)
87
+
88
+ def test_old_default_resume_does_not_prevent_later_explicit_retry(self):
89
+ with tempfile.TemporaryDirectory() as folder:
90
+ c = client(
91
+ page(tweet(A, ROOT), cursor="c1"), page(cursor="c2"), page(cursor="c3")
92
+ )
93
+ c.save_post_comments(ROOT, folder, max_depth=3, max_pages=5)
94
+ store = PostStore(
95
+ folder,
96
+ query={"kind": "post_comments", "root_post_id": ROOT, "max_depth": 3},
97
+ )
98
+ store.state["conversation"].pop("stalled_queue")
99
+ store.finish(complete=False, reason="partial_conversation")
100
+ c._x.conversation_page.reset_mock()
101
+ c.save_post_comments(ROOT, folder, max_depth=3, max_pages=1)
102
+ c._x.conversation_page.assert_not_called()
103
+ c._x.conversation_page.side_effect = [page(tweet(B, A))]
104
+ c.save_post_comments(
105
+ ROOT, folder, max_depth=3, max_pages=1, retry_stalled=True
106
+ )
107
+ c._x.conversation_page.assert_called_once_with(ROOT, cursor="c3")
108
+
109
+
110
+ class CommentStallIntegrationTests(unittest.TestCase):
111
+ def test_legacy_missing_archive_keeps_stop_unresolved(self):
112
+ from types import SimpleNamespace
113
+ from pyxcom.comments import restore_archived_stalls
114
+
115
+ with tempfile.TemporaryDirectory() as folder:
116
+ state = {
117
+ "queue": [],
118
+ "visited": [],
119
+ "empty_pages": {ROOT: 2},
120
+ "pagination_warnings": ["empty_page_limit"],
121
+ }
122
+ store = SimpleNamespace(
123
+ output_dir=Path(folder), state={"conversation": state}
124
+ )
125
+ restore_archived_stalls(store)
126
+ self.assertEqual(state["stalled_queue"], [])
127
+ self.assertEqual(state["unrecoverable_stalled_focals"], [ROOT])
128
+ c = client()
129
+ t = traversal(
130
+ c, records={ROOT: c.get_post(ROOT)}, state=state, retry_stalled=True
131
+ )
132
+ list(t.pages())
133
+ self.assertFalse(t.complete)
134
+ c._x.conversation_page.assert_not_called()
135
+
136
+ def test_rate_limit_preserves_explicit_retry_task(self):
137
+ from pyxcom import RateLimitError
138
+
139
+ c, t = CommentStallTests().stalled()
140
+ c._x.conversation_page.side_effect = [RateLimitError("limited")]
141
+ resumed = traversal(c, records=t.records, state=t.state, retry_stalled=True)
142
+ list(resumed.pages())
143
+ self.assertEqual(resumed.reason, "rate_limited")
144
+ self.assertEqual(resumed.state["queue"], [[ROOT, "c3"]])
145
+ self.assertFalse(resumed.complete)
146
+
147
+ def test_cli_forwards_explicit_comment_retry(self):
148
+ import contextlib
149
+ import io
150
+ from unittest.mock import patch, MagicMock
151
+ from pyxcom.cli import main
152
+
153
+ with tempfile.TemporaryDirectory() as directory:
154
+ factory = MagicMock()
155
+ saved = factory.return_value.__enter__.return_value.save_post_comments
156
+ saved.return_value.to_dict.return_value = {"complete": False}
157
+ with (
158
+ patch("pyxcom.cli.XClient", factory),
159
+ contextlib.redirect_stdout(io.StringIO()),
160
+ ):
161
+ code = main(
162
+ [
163
+ "post-comments",
164
+ ROOT,
165
+ "--output-dir",
166
+ directory,
167
+ "--retry-stalled",
168
+ ]
169
+ )
170
+ self.assertEqual(code, 0)
171
+ self.assertTrue(saved.call_args.kwargs["retry_stalled"])
172
+
173
+
174
+ class CommentRetryBindingTests(unittest.TestCase):
175
+ def test_wrong_or_older_request_cursor_is_not_recovered(self):
176
+ from types import SimpleNamespace
177
+ import hashlib
178
+ from pyxcom.comments import restore_archived_stalls
179
+
180
+ for logged_cursor, focal in [
181
+ ("never-requested", ROOT),
182
+ ("earlier", ROOT),
183
+ ("last", A),
184
+ ]:
185
+ with (
186
+ self.subTest(cursor=logged_cursor, focal=focal),
187
+ tempfile.TemporaryDirectory() as folder,
188
+ ):
189
+ path = Path(folder)
190
+ raw = path / ".pyxcom/raw"
191
+ raw.mkdir(parents=True)
192
+ payload = json.dumps(page(cursor="unbound-next")).encode()
193
+ (raw / "page.json").write_bytes(payload)
194
+ record = {
195
+ "operation": "TweetDetail",
196
+ "variables": {"focalTweetId": focal, "cursor": logged_cursor},
197
+ "raw_path": ".pyxcom/raw/page.json",
198
+ "raw_sha256": hashlib.sha256(payload).hexdigest(),
199
+ }
200
+ (path / ".pyxcom/collection_log.jsonl").write_text(
201
+ json.dumps(record) + "\n"
202
+ )
203
+ state = {
204
+ "pagination_warnings": ["empty_page_limit"],
205
+ "empty_pages": {ROOT: 2},
206
+ "visited": [[ROOT, "earlier"], [ROOT, "last"]],
207
+ "queue": [],
208
+ }
209
+ restore_archived_stalls(
210
+ SimpleNamespace(output_dir=path, state={"conversation": state})
211
+ )
212
+ self.assertEqual(state["stalled_queue"], [])
213
+ self.assertEqual(state["unrecoverable_stalled_focals"], [ROOT])
214
+
215
+ def test_pool_resume_forwards_comment_retry_and_keeps_observer(self):
216
+ from unittest.mock import patch
217
+ from test_pool import FakeClient
218
+ from pyxcom import XClientPool, LoginConfig, CollectionJob
219
+ from pyxcom.models import CollectionResult
220
+
221
+ calls = []
222
+ c = FakeClient("100")
223
+
224
+ def comments(
225
+ target,
226
+ output,
227
+ *,
228
+ max_pages=None,
229
+ max_depth=2,
230
+ max_comments=100,
231
+ retry_stalled=False,
232
+ ):
233
+ calls.append(retry_stalled)
234
+ return CollectionResult(
235
+ Path(output),
236
+ 3 if retry_stalled else 2,
237
+ len(calls),
238
+ retry_stalled,
239
+ "visible_source_end" if retry_stalled else "partial_conversation",
240
+ )
241
+
242
+ c.save_post_comments = comments
243
+ with (
244
+ tempfile.TemporaryDirectory() as tmp,
245
+ patch("pyxcom.pool.XClient.from_login", return_value=c),
246
+ ):
247
+ with XClientPool(
248
+ [LoginConfig("a", profile="A")], state_dir=Path(tmp) / "queue"
249
+ ) as pool:
250
+ pool.add_job(
251
+ CollectionJob("root", "post_comments", ROOT, Path(tmp) / "tree")
252
+ )
253
+ pool.run(max_steps=1)
254
+ pool.resume_job("root", retry_stalled=True)
255
+ final = pool.run(max_steps=1)
256
+ self.assertEqual(final["jobs"]["root"]["observer_id"], "100")
257
+ self.assertEqual(final["jobs"]["root"]["status"], "complete")
258
+ self.assertEqual(calls, [False, True])
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes