pyXcom 1.3.0__tar.gz → 1.3.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 (113) hide show
  1. {pyxcom-1.3.0 → pyxcom-1.3.1}/CHANGELOG.md +18 -0
  2. {pyxcom-1.3.0 → pyxcom-1.3.1}/CITATION.cff +1 -1
  3. {pyxcom-1.3.0 → pyxcom-1.3.1}/PKG-INFO +1 -1
  4. {pyxcom-1.3.0 → pyxcom-1.3.1}/docs/comment-ancestry.md +25 -7
  5. {pyxcom-1.3.0 → pyxcom-1.3.1}/pyproject.toml +1 -1
  6. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/__init__.py +1 -1
  7. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/ancestry.py +88 -41
  8. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_comment_ancestry.py +129 -3
  9. {pyxcom-1.3.0 → pyxcom-1.3.1}/.gitignore +0 -0
  10. {pyxcom-1.3.0 → pyxcom-1.3.1}/LICENSE +0 -0
  11. {pyxcom-1.3.0 → pyxcom-1.3.1}/NOTICE +0 -0
  12. {pyxcom-1.3.0 → pyxcom-1.3.1}/README.md +0 -0
  13. {pyxcom-1.3.0 → pyxcom-1.3.1}/benchmarks/LIVE_RESULTS.md +0 -0
  14. {pyxcom-1.3.0 → pyxcom-1.3.1}/benchmarks/README.md +0 -0
  15. {pyxcom-1.3.0 → pyxcom-1.3.1}/benchmarks/REPLAY_RESULTS.md +0 -0
  16. {pyxcom-1.3.0 → pyxcom-1.3.1}/benchmarks/STORAGE_RESULTS.md +0 -0
  17. {pyxcom-1.3.0 → pyxcom-1.3.1}/benchmarks/common.py +0 -0
  18. {pyxcom-1.3.0 → pyxcom-1.3.1}/benchmarks/environment-lock.txt +0 -0
  19. {pyxcom-1.3.0 → pyxcom-1.3.1}/benchmarks/live_arm.py +0 -0
  20. {pyxcom-1.3.0 → pyxcom-1.3.1}/benchmarks/live_compare.py +0 -0
  21. {pyxcom-1.3.0 → pyxcom-1.3.1}/benchmarks/live_pool.py +0 -0
  22. {pyxcom-1.3.0 → pyxcom-1.3.1}/benchmarks/metric-batch-results.json +0 -0
  23. {pyxcom-1.3.0 → pyxcom-1.3.1}/benchmarks/metric_batch.py +0 -0
  24. {pyxcom-1.3.0 → pyxcom-1.3.1}/benchmarks/normalize.py +0 -0
  25. {pyxcom-1.3.0 → pyxcom-1.3.1}/benchmarks/pool_summary.py +0 -0
  26. {pyxcom-1.3.0 → pyxcom-1.3.1}/benchmarks/replay.py +0 -0
  27. {pyxcom-1.3.0 → pyxcom-1.3.1}/benchmarks/requirements.txt +0 -0
  28. {pyxcom-1.3.0 → pyxcom-1.3.1}/docs/api.md +0 -0
  29. {pyxcom-1.3.0 → pyxcom-1.3.1}/docs/datasets.md +0 -0
  30. {pyxcom-1.3.0 → pyxcom-1.3.1}/docs/discovery-content.md +0 -0
  31. {pyxcom-1.3.0 → pyxcom-1.3.1}/docs/pool.md +0 -0
  32. {pyxcom-1.3.0 → pyxcom-1.3.1}/docs/profile-observations.md +0 -0
  33. {pyxcom-1.3.0 → pyxcom-1.3.1}/docs/publishing.md +0 -0
  34. {pyxcom-1.3.0 → pyxcom-1.3.1}/docs/recovery.md +0 -0
  35. {pyxcom-1.3.0 → pyxcom-1.3.1}/docs/release-1.0-plan.md +0 -0
  36. {pyxcom-1.3.0 → pyxcom-1.3.1}/docs/release-1.0-validation.md +0 -0
  37. {pyxcom-1.3.0 → pyxcom-1.3.1}/docs/stability-plan.md +0 -0
  38. {pyxcom-1.3.0 → pyxcom-1.3.1}/docs/stability.md +0 -0
  39. {pyxcom-1.3.0 → pyxcom-1.3.1}/docs/timeline-pagination-plan.md +0 -0
  40. {pyxcom-1.3.0 → pyxcom-1.3.1}/docs/timeline-pagination.md +0 -0
  41. {pyxcom-1.3.0 → pyxcom-1.3.1}/examples/basic.py +0 -0
  42. {pyxcom-1.3.0 → pyxcom-1.3.1}/examples/research_networks.py +0 -0
  43. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/_persistence.py +0 -0
  44. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/_pool_locks.py +0 -0
  45. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/assets.py +0 -0
  46. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/auth.py +0 -0
  47. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/batch.py +0 -0
  48. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/cli.py +0 -0
  49. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/client.py +0 -0
  50. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/comments.py +0 -0
  51. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/datasets.py +0 -0
  52. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/discovery.py +0 -0
  53. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/errors.py +0 -0
  54. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/integrity.py +0 -0
  55. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/layout.py +0 -0
  56. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/models.py +0 -0
  57. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/networks.py +0 -0
  58. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/parse.py +0 -0
  59. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/pool.py +0 -0
  60. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/profiles.py +0 -0
  61. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/provenance.py +0 -0
  62. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/recovery.py +0 -0
  63. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/schema.py +0 -0
  64. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/search.py +0 -0
  65. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/storage.py +0 -0
  66. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/tables.py +0 -0
  67. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/transport.py +0 -0
  68. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/validate.py +0 -0
  69. {pyxcom-1.3.0 → pyxcom-1.3.1}/src/pyxcom/validate_tables.py +0 -0
  70. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_api_interruption.py +0 -0
  71. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_assets.py +0 -0
  72. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_auth.py +0 -0
  73. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_batch_report.py +0 -0
  74. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_benchmark_harness.py +0 -0
  75. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_benchmark_replay.py +0 -0
  76. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_capability_cli.py +0 -0
  77. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_capability_transport.py +0 -0
  78. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_comment_stalls.py +0 -0
  79. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_comment_transport.py +0 -0
  80. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_comments.py +0 -0
  81. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_datasets.py +0 -0
  82. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_direct_search.py +0 -0
  83. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_discovery.py +0 -0
  84. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_discovery_content.py +0 -0
  85. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_discovery_pagination.py +0 -0
  86. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_integrity.py +0 -0
  87. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_layout.py +0 -0
  88. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_metric_batch.py +0 -0
  89. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_networks.py +0 -0
  90. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_observations.py +0 -0
  91. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_parsing.py +0 -0
  92. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_persistence.py +0 -0
  93. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_pool.py +0 -0
  94. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_pool_cli.py +0 -0
  95. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_pool_locks.py +0 -0
  96. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_pool_provenance.py +0 -0
  97. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_profile_batch.py +0 -0
  98. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_profile_history.py +0 -0
  99. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_project_metadata.py +0 -0
  100. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_public_api.py +0 -0
  101. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_recovery.py +0 -0
  102. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_recovery_cli.py +0 -0
  103. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_relational_integration.py +0 -0
  104. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_request_control.py +0 -0
  105. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_schema.py +0 -0
  106. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_search_scope.py +0 -0
  107. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_search_storage.py +0 -0
  108. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_session_identity.py +0 -0
  109. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_tables.py +0 -0
  110. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_timeline_pagination.py +0 -0
  111. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_transport.py +0 -0
  112. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_validate_date_scope.py +0 -0
  113. {pyxcom-1.3.0 → pyxcom-1.3.1}/tests/test_validation_shapes.py +0 -0
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.1 — 2026-10-13
4
+
5
+ ### Fixed
6
+
7
+ - Distinguish malformed or unparseable parent responses (`response_unparsed`)
8
+ from recognized empty results and tombstones (`parent_not_returned`).
9
+ - Honor inherited source observer bindings and reject malformed markers by reusing
10
+ the package's ownership reader; check ownership again before publication.
11
+ - Reject supplement destinations owned by another pool before network requests,
12
+ including inherited ownership, and check again before publication.
13
+ - Publish ancestry output as one staged generation. Ordinary write failures leave
14
+ no partial final directory blocking retries.
15
+
16
+ ### Added
17
+
18
+ - `blocked_post_id` identifies the actual point of failure along a parent chain,
19
+ while preserving the original `parent_post_id`.
20
+
3
21
  ## 1.3.0 — 2026-10-13
4
22
 
5
23
  ### Added
@@ -6,7 +6,7 @@ authors:
6
6
  - family-names: Wang
7
7
  given-names: Haocheng
8
8
  affiliation: Communication University of China
9
- version: 1.3.0
9
+ version: 1.3.1
10
10
  date-released: 2026-10-13
11
11
  license: Apache-2.0
12
12
  repository-code: "https://github.com/haochengw372-hash/pyXcom"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyXcom
3
- Version: 1.3.0
3
+ Version: 1.3.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
@@ -20,6 +20,8 @@ The source must pass retained-record and checkpoint integrity verification and
20
20
  must be a `post_comments` collection. Both scopes are locked; overlapping paths,
21
21
  existing destinations and symlink source contents are rejected. Source files,
22
22
  cursors, roles, counts and partial status remain unchanged.
23
+ Destinations inside another pool's owned scope are rejected before network requests
24
+ and checked again before publication; an idle pool still owns its persisted scope.
23
25
 
24
26
  ## Lookup rules
25
27
 
@@ -30,8 +32,10 @@ resolved only when it reaches the exact requested seed, without a cycle. Other
30
32
  branches of a reply-seeded conversation are not admitted.
31
33
 
32
34
  Before the first lookup, the client verifies its current identity. If the source
33
- has a pool observer binding, that identity must match; a mismatch raises before
34
- parent content is requested. Older unbound sources remain explicitly unbound in
35
+ has a pool observer binding, including one inherited from an ancestor directory,
36
+ that identity must match; a mismatch raises before parent content is requested.
37
+ Malformed ownership markers are rejected before identity or parent requests, and
38
+ the marker is checked again before publication. Older unbound sources remain explicitly unbound in
35
39
  the manifest. No observer is inferred retroactively and no account rotation occurs.
36
40
 
37
41
  `lookup_limit` is a nonnegative integer limiting single-post lookup calls, not
@@ -52,6 +56,9 @@ delay; a rate limit or request error stops further parent lookups in that run.
52
56
  `seed_post_id` is the requested collection seed; `root_post_id` is the platform
53
57
  conversation root. `chain_ids` lists verified retained IDs from the pending record
54
58
  back toward the seed; an unresolved chain does not include the unreturned parent.
59
+ `blocked_post_id` identifies the first post where that chain could not be verified,
60
+ including a deeper missing parent after the original parent was fetched. It is
61
+ blank for resolved chains; `parent_post_id` always preserves the original reply link.
55
62
  `seed_relative_depth` counts links to the seed and is blank until that chain is
56
63
  resolved. `depth` counts links to the platform root and also requires retained
57
64
  evidence between a reply seed and that root; otherwise it stays blank. This method
@@ -64,13 +71,18 @@ records never alter the original `posts.csv` or `comments.csv`.
64
71
 
65
72
  Statuses include `resolved`, `lookup_budget`, `parent_not_returned`, `rate_limited`,
66
73
  `request_error`, `identity_conflict`, `conversation_conflict`, `outside_seed_branch`,
67
- `cycle` and `protected_parent`. A successful response without a parseable parent
68
- is `parent_not_returned`; that does not establish deletion, protection or absence
69
- on the platform. Explicitly protected parent content is not retained or archived.
74
+ `cycle`, `response_unparsed` and `protected_parent`. A recognized empty result or
75
+ tombstone is `parent_not_returned`; that does not establish deletion, protection
76
+ or absence on the platform. A changed response shape or invalid returned post is
77
+ `response_unparsed`, with raw evidence retained for diagnosis. Explicitly protected
78
+ parent content is not retained or archived.
70
79
 
71
80
  Each invocation uses a new output directory. There is no supplement resume or
72
- automatic merging into the source. Output is published after traversal; a process
73
- termination before publication can lose that run's in-memory supplement. Retain
81
+ automatic merging into the source. After traversal, all files are written in a
82
+ temporary sibling directory, then renamed to the requested destination together.
83
+ Ordinary write failures remove staging and leave the destination free for retry.
84
+ A process termination before publication can lose that run's in-memory supplement;
85
+ an abrupt kill can also leave an unpublished hidden staging directory. Retain
74
86
  the original source and compare dated supplements explicitly. The manifest's
75
87
  `collection_complete` and `source_collection_reason` copy the original collection's
76
88
  status, even if every pending chain has now been resolved.
@@ -92,3 +104,9 @@ Eighteen focused offline regression tests cover exact chains, shared parents,
92
104
  reply-seed boundaries, cycles, budgets, source integrity, identity mismatch,
93
105
  malformed responses and protected content. The complete suite contained 454
94
106
  passing tests at this release.
107
+
108
+ The 1.3.1 review expanded this to 25 focused tests and 461 complete-suite tests.
109
+ It also replayed the three previously archived lookup responses offline, preserving
110
+ the two resolved chains and one empty parent result. No new network collection
111
+ was used for that replay. Injected JSONL, raw-response and manifest write failures
112
+ each left no final directory and allowed a retry to the same destination.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pyXcom"
7
- version = "1.3.0"
7
+ version = "1.3.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.3.0"
53
+ __version__ = "1.3.1"
@@ -6,14 +6,16 @@ import time
6
6
  from contextlib import ExitStack
7
7
  from dataclasses import replace
8
8
  from pathlib import Path
9
+ from tempfile import TemporaryDirectory
9
10
  from typing import TYPE_CHECKING
10
11
 
11
- from ._persistence import append_jsonl, atomic_json, read_jsonl
12
+ from ._persistence import append_jsonl, atomic_json, atomic_text, read_jsonl
12
13
  from ._pool_locks import ScopeLock
13
14
  from .errors import IntegrityError, ParseError, PyXcomError, RateLimitError
14
15
  from .integrity import assess_recovery
15
16
  from .models import Post
16
17
  from .parse import single_post
18
+ from .provenance import _owner, assert_collection_owner
17
19
  from .recovery import _files
18
20
  from .transport import now_utc
19
21
 
@@ -46,6 +48,74 @@ def _protected(payload: dict) -> bool:
46
48
  )
47
49
 
48
50
 
51
+ def _parent_not_returned(payload: dict) -> bool:
52
+ """Recognize explicit empty slots/tombstones, not unknown response shapes."""
53
+ data = payload.get("data")
54
+ if payload.get("errors") or not isinstance(data, dict) or "tweetResult" not in data:
55
+ return False
56
+ slot = data["tweetResult"]
57
+ if slot is None:
58
+ return True
59
+ if not isinstance(slot, dict):
60
+ return False
61
+ result = slot.get("result")
62
+ return (
63
+ result is None
64
+ or result == {}
65
+ or (isinstance(result, dict) and result.get("__typename") == "TweetTombstone")
66
+ )
67
+
68
+
69
+ def _publish_supplement(
70
+ target: Path,
71
+ pending: dict,
72
+ fetched: dict[str, Post],
73
+ rows: list[dict],
74
+ raw_responses: dict[str, dict],
75
+ report: dict,
76
+ ) -> dict:
77
+ """Publish a complete generation; ordinary write failures leave no final target."""
78
+ assert_collection_owner(target)
79
+ target.parent.mkdir(parents=True, exist_ok=True)
80
+ with TemporaryDirectory(prefix=".pyxcom-ancestry-", dir=target.parent) as temporary:
81
+ staged = Path(temporary)
82
+ (staged / "raw").mkdir()
83
+ for name, records in (
84
+ ("ancestor_posts.jsonl", (p.to_dict() for p in fetched.values())),
85
+ ("pending_posts.jsonl", pending.values()),
86
+ ):
87
+ append_jsonl(staged / name, records)
88
+ with atomic_text(
89
+ staged / "resolution.csv", encoding="utf-8-sig", newline=""
90
+ ) as stream:
91
+ writer = csv.DictWriter(
92
+ stream,
93
+ fieldnames=[
94
+ "post_id",
95
+ "root_post_id",
96
+ "seed_post_id",
97
+ "parent_post_id",
98
+ "ancestry_status",
99
+ "depth",
100
+ "seed_relative_depth",
101
+ "chain_ids",
102
+ "within_requested_bounds",
103
+ "blocked_post_id",
104
+ ],
105
+ )
106
+ writer.writeheader()
107
+ writer.writerows(rows)
108
+ for identifier, payload in raw_responses.items():
109
+ atomic_json(staged / "raw" / f"{identifier}.json", payload)
110
+ report["files_sha256"] = _files(staged)
111
+ atomic_json(staged / "manifest.json", report)
112
+ if target.exists():
113
+ raise FileExistsError("Ancestry supplement already exists")
114
+ assert_collection_owner(target)
115
+ staged.rename(target)
116
+ return report
117
+
118
+
49
119
  def save_comment_ancestry(
50
120
  client: "XClient",
51
121
  source_dir: str | Path,
@@ -72,6 +142,7 @@ def save_comment_ancestry(
72
142
  stack.enter_context(ScopeLock(scope))
73
143
  if target.exists():
74
144
  raise FileExistsError("Ancestry supplement already exists")
145
+ assert_collection_owner(target)
75
146
  assessment = assess_recovery(source)
76
147
  if not assessment["allowed"] or assessment["record_integrity"] != "verified":
77
148
  raise IntegrityError(
@@ -89,13 +160,8 @@ def save_comment_ancestry(
89
160
  posts.update({key: Post(**value) for key, value in pending.items()})
90
161
  root = str(query["root_post_id"])
91
162
  conversation_id = str(conversation.get("conversation_id", root))
92
- owner_path = source / ".pyxcom/pool-owner.json"
93
- owner = (
94
- json.loads(owner_path.read_text(encoding="utf-8"))
95
- if owner_path.exists()
96
- else {}
97
- )
98
- expected_observer = owner.get("observer_id")
163
+ owner = _owner(source)
164
+ expected_observer = (owner or {}).get("observer_id")
99
165
  verified_observer = None
100
166
  fetched: dict[str, Post] = {}
101
167
  failures: dict[str, str] = {}
@@ -132,7 +198,11 @@ def save_comment_ancestry(
132
198
  try:
133
199
  post = single_post(payload, captured_at_utc=now_utc())
134
200
  except (ParseError, ValueError, TypeError, KeyError, AttributeError):
135
- failures[identifier] = "parent_not_returned"
201
+ failures[identifier] = (
202
+ "parent_not_returned"
203
+ if _parent_not_returned(payload)
204
+ else "response_unparsed"
205
+ )
136
206
  return None, failures[identifier]
137
207
  if post.id != identifier:
138
208
  failures[identifier] = "identity_conflict"
@@ -177,7 +247,9 @@ def save_comment_ancestry(
177
247
  chain = [identifier]
178
248
  current = post
179
249
  status = "resolved"
250
+ blocked_id = None
180
251
  while current.id != root:
252
+ blocked_id = current.id
181
253
  if (
182
254
  current.conversation_id != conversation_id
183
255
  or current.reposted_post_id
@@ -185,6 +257,7 @@ def save_comment_ancestry(
185
257
  status = "conversation_conflict"
186
258
  break
187
259
  parent_id = current.in_reply_to_id
260
+ blocked_id = parent_id or current.id
188
261
  if parent_id is None or (
189
262
  parent_id == conversation_id and conversation_id != root
190
263
  ):
@@ -204,6 +277,7 @@ def save_comment_ancestry(
204
277
  break
205
278
  chain.append(parent_id)
206
279
  current = parent
280
+ blocked_id = None
207
281
  seed_depth = len(chain) - 1 if status == "resolved" else None
208
282
  within = None
209
283
  if seed_depth is not None:
@@ -224,38 +298,11 @@ def save_comment_ancestry(
224
298
  "seed_relative_depth": seed_depth,
225
299
  "chain_ids": json.dumps(chain),
226
300
  "within_requested_bounds": within,
301
+ "blocked_post_id": blocked_id,
227
302
  }
228
303
  )
229
- if _files(source) != assessment["input_sha256"]:
304
+ if _files(source) != assessment["input_sha256"] or _owner(source) != owner:
230
305
  raise IntegrityError("Source changed during ancestry verification")
231
- target.mkdir(parents=True, exist_ok=False)
232
- (target / "raw").mkdir()
233
- for name, records in (
234
- ("ancestor_posts.jsonl", (p.to_dict() for p in fetched.values())),
235
- ("pending_posts.jsonl", pending.values()),
236
- ):
237
- append_jsonl(target / name, records)
238
- with (target / "resolution.csv").open(
239
- "w", encoding="utf-8-sig", newline=""
240
- ) as stream:
241
- writer = csv.DictWriter(
242
- stream,
243
- fieldnames=[
244
- "post_id",
245
- "root_post_id",
246
- "seed_post_id",
247
- "parent_post_id",
248
- "ancestry_status",
249
- "depth",
250
- "seed_relative_depth",
251
- "chain_ids",
252
- "within_requested_bounds",
253
- ],
254
- )
255
- writer.writeheader()
256
- writer.writerows(rows)
257
- for identifier, payload in raw_responses.items():
258
- atomic_json(target / "raw" / f"{identifier}.json", payload)
259
306
  counts = {
260
307
  status: sum(r["ancestry_status"] == status for r in rows)
261
308
  for status in sorted({r["ancestry_status"] for r in rows})
@@ -280,7 +327,7 @@ def save_comment_ancestry(
280
327
  "context_records": len(fetched),
281
328
  "collection_complete": assessment["complete"],
282
329
  "source_collection_reason": assessment["reason"],
283
- "files_sha256": _files(target),
284
330
  }
285
- atomic_json(target / "manifest.json", report)
286
- return report
331
+ return _publish_supplement(
332
+ target, pending, fetched, rows, raw_responses, report
333
+ )
@@ -3,7 +3,7 @@ import json
3
3
  import tempfile
4
4
  import unittest
5
5
  from pathlib import Path
6
- from unittest.mock import Mock
6
+ from unittest.mock import Mock, patch
7
7
 
8
8
  from test_comments import ROOT, A, B, C, client, page, tweet
9
9
  from pyxcom import IntegrityError, RateLimitError
@@ -14,6 +14,15 @@ def lookup_payload(post):
14
14
  return {"data": {"tweetResult": {"result": post}}}
15
15
 
16
16
 
17
+ def owner_marker(observer_id, source):
18
+ return {
19
+ "observer_id": observer_id,
20
+ "login_id": "test_login",
21
+ "job_id": "test_job",
22
+ "pool_state_dir": str(source.parent / "pool"),
23
+ }
24
+
25
+
17
26
  class AncestryTests(unittest.TestCase):
18
27
  def make_source(self, tmp, posts):
19
28
  source = Path(tmp) / "source"
@@ -117,13 +126,53 @@ class AncestryTests(unittest.TestCase):
117
126
  with tempfile.TemporaryDirectory() as tmp:
118
127
  c, source = self.make_source(tmp, [tweet(B, A)])
119
128
  (source / ".pyxcom/pool-owner.json").write_text(
120
- json.dumps({"observer_id": "200"})
129
+ json.dumps(owner_marker("200", source))
121
130
  )
122
131
  c.check_login_identity = Mock(return_value="100")
123
132
  c.get_raw_post = Mock()
124
133
  with self.assertRaises(ValueError):
125
134
  c.save_comment_ancestry(source, Path(tmp) / "resolved")
126
135
  c.get_raw_post.assert_not_called()
136
+ c.check_login_identity.assert_called_once()
137
+
138
+ def test_inherited_observer_mismatch_rejects_before_lookup(self):
139
+ with tempfile.TemporaryDirectory() as tmp:
140
+ c, source = self.make_source(Path(tmp) / "owned", [tweet(B, A)])
141
+ marker = source.parent / ".pyxcom/pool-owner.json"
142
+ marker.parent.mkdir()
143
+ marker.write_text(json.dumps(owner_marker("200", source)))
144
+ c.check_login_identity = Mock(return_value="100")
145
+ c.get_raw_post = Mock()
146
+ with self.assertRaises(ValueError):
147
+ c.save_comment_ancestry(source, Path(tmp) / "resolved")
148
+ c.get_raw_post.assert_not_called()
149
+ self.assertFalse((Path(tmp) / "resolved").exists())
150
+ c.check_login_identity.assert_called_once()
151
+
152
+ def test_other_pool_owned_destination_rejects_before_network(self):
153
+ with tempfile.TemporaryDirectory() as tmp:
154
+ c, source = self.make_source(tmp, [tweet(B, A)])
155
+ other = Path(tmp) / "other_pool_output"
156
+ marker = other / ".pyxcom/pool-owner.json"
157
+ marker.parent.mkdir(parents=True)
158
+ marker.write_text(json.dumps(owner_marker("200", other)))
159
+ before = self.files(other)
160
+ c.check_login_identity = Mock(return_value="100")
161
+ c.get_raw_post = Mock(return_value=lookup_payload(tweet(A, ROOT)))
162
+ with self.assertRaises(IntegrityError):
163
+ c.save_comment_ancestry(source, other / "supplement")
164
+ c.check_login_identity.assert_not_called()
165
+ self.assertEqual(self.files(other), before)
166
+
167
+ def test_malformed_owner_marker_rejects_before_identity_check(self):
168
+ with tempfile.TemporaryDirectory() as tmp:
169
+ c, source = self.make_source(tmp, [tweet(B, A)])
170
+ (source / ".pyxcom/pool-owner.json").write_text('{"observer_id": null}')
171
+ c.check_login_identity = Mock(return_value="100")
172
+ c.get_raw_post = Mock(return_value={"data": {"tweetResult": {}}})
173
+ with self.assertRaises(IntegrityError):
174
+ c.save_comment_ancestry(source, Path(tmp) / "resolved")
175
+ c.check_login_identity.assert_not_called()
127
176
 
128
177
  def test_shared_missing_parent_is_fetched_once(self):
129
178
  with tempfile.TemporaryDirectory() as tmp:
@@ -222,9 +271,86 @@ class AncestryTests(unittest.TestCase):
222
271
  target = Path(tmp) / "resolved"
223
272
  report = c.save_comment_ancestry(source, target)
224
273
  self.assertEqual(report["resolved_records"], 1)
225
- self.assertEqual(report["status_counts"]["parent_not_returned"], 1)
274
+ self.assertEqual(report["status_counts"]["response_unparsed"], 1)
226
275
  self.assertEqual(report["context_records"], 1)
227
276
 
277
+ def test_invalid_returned_parent_is_parse_failure_not_absence(self):
278
+ with tempfile.TemporaryDirectory() as tmp:
279
+ c, source = self.make_source(tmp, [tweet(B, A)])
280
+ parent = tweet(A, ROOT)
281
+ del parent["legacy"]["created_at"]
282
+ c.check_login_identity = Mock(return_value="100")
283
+ c.get_raw_post = Mock(return_value=lookup_payload(parent))
284
+ target = Path(tmp) / "resolved"
285
+ report = c.save_comment_ancestry(source, target)
286
+ self.assertEqual(
287
+ self.rows(target)[0]["ancestry_status"], "response_unparsed"
288
+ )
289
+ self.assertEqual(report["context_records"], 0)
290
+ self.assertTrue((target / "raw" / f"{A}.json").exists())
291
+
292
+ def test_tombstone_is_unreturned_not_unparsed(self):
293
+ with tempfile.TemporaryDirectory() as tmp:
294
+ c, source = self.make_source(tmp, [tweet(B, A)])
295
+ c.check_login_identity = Mock(return_value="100")
296
+ c.get_raw_post = Mock(
297
+ return_value=lookup_payload({"__typename": "TweetTombstone"})
298
+ )
299
+ target = Path(tmp) / "resolved"
300
+ c.save_comment_ancestry(source, target)
301
+ self.assertEqual(
302
+ self.rows(target)[0]["ancestry_status"], "parent_not_returned"
303
+ )
304
+
305
+ def test_deeper_blocked_id_is_distinct_from_original_parent(self):
306
+ with tempfile.TemporaryDirectory() as tmp:
307
+ c, source = self.make_source(tmp, [tweet(C, B)])
308
+ c.check_login_identity = Mock(return_value="100")
309
+ c.get_raw_post = Mock(
310
+ side_effect=[lookup_payload(tweet(B, A)), {"data": {"tweetResult": {}}}]
311
+ )
312
+ target = Path(tmp) / "resolved"
313
+ c.save_comment_ancestry(source, target)
314
+ row = self.rows(target)[0]
315
+ self.assertEqual(row["parent_post_id"], B)
316
+ self.assertEqual(row["blocked_post_id"], A)
317
+ self.assertEqual(row["ancestry_status"], "parent_not_returned")
318
+ self.assertEqual(json.loads(row["chain_ids"]), [C, B])
319
+
320
+ def test_publication_failure_leaves_no_partial_target_and_can_retry(self):
321
+ from pyxcom._persistence import atomic_json as write_json
322
+
323
+ for phase in ("jsonl", "raw", "manifest"):
324
+ with self.subTest(phase=phase), tempfile.TemporaryDirectory() as tmp:
325
+ c, source = self.make_source(tmp, [tweet(B, A)])
326
+ c.check_login_identity = Mock(return_value="100")
327
+ c.get_raw_post = Mock(return_value=lookup_payload(tweet(A, ROOT)))
328
+ target = Path(tmp) / "resolved"
329
+ before = self.files(source)
330
+
331
+ def fail_write(path, value):
332
+ if (phase == "raw" and path.parent.name == "raw") or (
333
+ phase == "manifest" and path.name == "manifest.json"
334
+ ):
335
+ raise OSError("injected write failure")
336
+ write_json(path, value)
337
+
338
+ failure = (
339
+ patch(
340
+ "pyxcom.ancestry.append_jsonl",
341
+ side_effect=OSError("injected write failure"),
342
+ )
343
+ if phase == "jsonl"
344
+ else patch("pyxcom.ancestry.atomic_json", side_effect=fail_write)
345
+ )
346
+ with failure, self.assertRaises(OSError):
347
+ c.save_comment_ancestry(source, target)
348
+ self.assertFalse(target.exists())
349
+ self.assertEqual(list(target.parent.glob(".pyxcom-ancestry-*")), [])
350
+ self.assertEqual(self.files(source), before)
351
+ report = c.save_comment_ancestry(source, target)
352
+ self.assertEqual(report["resolved_records"], 1)
353
+
228
354
  def test_paths_and_corruption_reject_before_network(self):
229
355
  with tempfile.TemporaryDirectory() as tmp:
230
356
  c, source = self.make_source(tmp, [tweet(B, A)])
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes