pyXcom 1.2.1__tar.gz → 1.3.0__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.2.1 → pyxcom-1.3.0}/CHANGELOG.md +13 -0
  2. {pyxcom-1.2.1 → pyxcom-1.3.0}/CITATION.cff +1 -1
  3. {pyxcom-1.2.1 → pyxcom-1.3.0}/PKG-INFO +19 -1
  4. {pyxcom-1.2.1 → pyxcom-1.3.0}/README.md +18 -0
  5. {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/api.md +14 -0
  6. pyxcom-1.3.0/docs/comment-ancestry.md +94 -0
  7. {pyxcom-1.2.1 → pyxcom-1.3.0}/pyproject.toml +1 -1
  8. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/__init__.py +1 -1
  9. pyxcom-1.3.0/src/pyxcom/ancestry.py +286 -0
  10. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/client.py +18 -0
  11. pyxcom-1.3.0/tests/test_comment_ancestry.py +294 -0
  12. {pyxcom-1.2.1 → pyxcom-1.3.0}/.gitignore +0 -0
  13. {pyxcom-1.2.1 → pyxcom-1.3.0}/LICENSE +0 -0
  14. {pyxcom-1.2.1 → pyxcom-1.3.0}/NOTICE +0 -0
  15. {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/LIVE_RESULTS.md +0 -0
  16. {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/README.md +0 -0
  17. {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/REPLAY_RESULTS.md +0 -0
  18. {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/STORAGE_RESULTS.md +0 -0
  19. {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/common.py +0 -0
  20. {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/environment-lock.txt +0 -0
  21. {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/live_arm.py +0 -0
  22. {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/live_compare.py +0 -0
  23. {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/live_pool.py +0 -0
  24. {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/metric-batch-results.json +0 -0
  25. {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/metric_batch.py +0 -0
  26. {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/normalize.py +0 -0
  27. {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/pool_summary.py +0 -0
  28. {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/replay.py +0 -0
  29. {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/requirements.txt +0 -0
  30. {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/datasets.md +0 -0
  31. {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/discovery-content.md +0 -0
  32. {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/pool.md +0 -0
  33. {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/profile-observations.md +0 -0
  34. {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/publishing.md +0 -0
  35. {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/recovery.md +0 -0
  36. {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/release-1.0-plan.md +0 -0
  37. {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/release-1.0-validation.md +0 -0
  38. {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/stability-plan.md +0 -0
  39. {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/stability.md +0 -0
  40. {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/timeline-pagination-plan.md +0 -0
  41. {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/timeline-pagination.md +0 -0
  42. {pyxcom-1.2.1 → pyxcom-1.3.0}/examples/basic.py +0 -0
  43. {pyxcom-1.2.1 → pyxcom-1.3.0}/examples/research_networks.py +0 -0
  44. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/_persistence.py +0 -0
  45. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/_pool_locks.py +0 -0
  46. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/assets.py +0 -0
  47. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/auth.py +0 -0
  48. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/batch.py +0 -0
  49. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/cli.py +0 -0
  50. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/comments.py +0 -0
  51. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/datasets.py +0 -0
  52. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/discovery.py +0 -0
  53. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/errors.py +0 -0
  54. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/integrity.py +0 -0
  55. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/layout.py +0 -0
  56. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/models.py +0 -0
  57. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/networks.py +0 -0
  58. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/parse.py +0 -0
  59. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/pool.py +0 -0
  60. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/profiles.py +0 -0
  61. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/provenance.py +0 -0
  62. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/recovery.py +0 -0
  63. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/schema.py +0 -0
  64. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/search.py +0 -0
  65. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/storage.py +0 -0
  66. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/tables.py +0 -0
  67. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/transport.py +0 -0
  68. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/validate.py +0 -0
  69. {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/validate_tables.py +0 -0
  70. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_api_interruption.py +0 -0
  71. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_assets.py +0 -0
  72. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_auth.py +0 -0
  73. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_batch_report.py +0 -0
  74. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_benchmark_harness.py +0 -0
  75. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_benchmark_replay.py +0 -0
  76. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_capability_cli.py +0 -0
  77. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_capability_transport.py +0 -0
  78. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_comment_stalls.py +0 -0
  79. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_comment_transport.py +0 -0
  80. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_comments.py +0 -0
  81. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_datasets.py +0 -0
  82. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_direct_search.py +0 -0
  83. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_discovery.py +0 -0
  84. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_discovery_content.py +0 -0
  85. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_discovery_pagination.py +0 -0
  86. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_integrity.py +0 -0
  87. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_layout.py +0 -0
  88. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_metric_batch.py +0 -0
  89. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_networks.py +0 -0
  90. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_observations.py +0 -0
  91. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_parsing.py +0 -0
  92. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_persistence.py +0 -0
  93. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_pool.py +0 -0
  94. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_pool_cli.py +0 -0
  95. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_pool_locks.py +0 -0
  96. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_pool_provenance.py +0 -0
  97. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_profile_batch.py +0 -0
  98. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_profile_history.py +0 -0
  99. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_project_metadata.py +0 -0
  100. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_public_api.py +0 -0
  101. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_recovery.py +0 -0
  102. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_recovery_cli.py +0 -0
  103. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_relational_integration.py +0 -0
  104. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_request_control.py +0 -0
  105. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_schema.py +0 -0
  106. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_search_scope.py +0 -0
  107. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_search_storage.py +0 -0
  108. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_session_identity.py +0 -0
  109. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_tables.py +0 -0
  110. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_timeline_pagination.py +0 -0
  111. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_transport.py +0 -0
  112. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_validate_date_scope.py +0 -0
  113. {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_validation_shapes.py +0 -0
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0 — 2026-10-13
4
+
5
+ ### Added
6
+
7
+ - `XClient.save_comment_ancestry()` verifies missing comment parents through bounded,
8
+ deduplicated exact-ID lookups, using the source observer when bound.
9
+ - Write a separate ancestry supplement with verified seed-relative/platform depths,
10
+ explicit unresolved statuses, context-only parents and source/output hashes.
11
+ - Preserve original sampling roles, files, cursors and partial status; stop further
12
+ lookups on rate limits or request errors without rotating accounts.
13
+
14
+ No new runtime dependencies or changes to existing comment collection defaults.
15
+
3
16
  ## 1.2.1 — 2026-10-13
4
17
 
5
18
  ### Fixed
@@ -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.2.1
9
+ version: 1.3.0
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.2.1
3
+ Version: 1.3.0
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
@@ -193,6 +193,24 @@ Repeat the same seed, depth, dates and output directory to resume. Raise the com
193
193
 
194
194
  For a main-post seed, the root enters `posts.csv` and descendants enter `comments.csv`. A reply seed remains in `comments.csv`. `seed_post_id` and `seed_relative_depth` describe the requested branch; platform root, parent and depth fields describe the retained ancestry. Unknown ancestry stays unresolved. `get_post_comments()` and `iter_post_comments()` return descendants without writing a dataset.
195
195
 
196
+ When a saved tree contains pending replies with missing parents, you can verify
197
+ their exact parent chains in a separate supplement:
198
+
199
+ ```python
200
+ with XClient(profile="Default") as client:
201
+ report = client.save_comment_ancestry(
202
+ "data/conversation", "data/conversation_ancestry", lookup_limit=20,
203
+ )
204
+ ```
205
+
206
+ This reads a verified `post_comments` collection and looks up missing parent IDs
207
+ using the same observer when the source has a pool identity binding. It writes
208
+ `resolution.csv`, context-only `ancestor_posts.jsonl`, the original pending
209
+ records, raw responses and a hash manifest into a new directory. Source files,
210
+ sampling roles and completeness remain unchanged. The limit counts lookup calls;
211
+ identity checks and transport retries are additional HTTP attempts. See
212
+ [comment ancestry](docs/comment-ancestry.md) for field definitions and unresolved cases.
213
+
196
214
  ### Collect several accounts
197
215
 
198
216
  One client can collect several target accounts using the same authenticated browser session:
@@ -174,6 +174,24 @@ Repeat the same seed, depth, dates and output directory to resume. Raise the com
174
174
 
175
175
  For a main-post seed, the root enters `posts.csv` and descendants enter `comments.csv`. A reply seed remains in `comments.csv`. `seed_post_id` and `seed_relative_depth` describe the requested branch; platform root, parent and depth fields describe the retained ancestry. Unknown ancestry stays unresolved. `get_post_comments()` and `iter_post_comments()` return descendants without writing a dataset.
176
176
 
177
+ When a saved tree contains pending replies with missing parents, you can verify
178
+ their exact parent chains in a separate supplement:
179
+
180
+ ```python
181
+ with XClient(profile="Default") as client:
182
+ report = client.save_comment_ancestry(
183
+ "data/conversation", "data/conversation_ancestry", lookup_limit=20,
184
+ )
185
+ ```
186
+
187
+ This reads a verified `post_comments` collection and looks up missing parent IDs
188
+ using the same observer when the source has a pool identity binding. It writes
189
+ `resolution.csv`, context-only `ancestor_posts.jsonl`, the original pending
190
+ records, raw responses and a hash manifest into a new directory. Source files,
191
+ sampling roles and completeness remain unchanged. The limit counts lookup calls;
192
+ identity checks and transport retries are additional HTTP attempts. See
193
+ [comment ancestry](docs/comment-ancestry.md) for field definitions and unresolved cases.
194
+
177
195
  ### Collect several accounts
178
196
 
179
197
  One client can collect several target accounts using the same authenticated browser session:
@@ -98,6 +98,20 @@ result = client.save_post_comments(
98
98
  )
99
99
  ```
100
100
 
101
+ ### Verify missing comment ancestors
102
+
103
+ ```python
104
+ save_comment_ancestry(source_dir: str | Path, output_dir: str | Path, *,
105
+ lookup_limit: int = 20) -> dict
106
+ ```
107
+
108
+ Reads a verified saved `post_comments` source and writes a new, separate ancestry
109
+ supplement. Source scopes and sampling roles are immutable. Exact-parent lookups
110
+ are bounded and deduplicated; known source observers must match the current login.
111
+ `lookup_limit=0` makes no network calls. A resolved chain establishes descent from
112
+ the requested seed; it does not establish exhaustive collection. See
113
+ [comment ancestry](comment-ancestry.md) for outputs, depth definitions and failure handling.
114
+
101
115
  The manifest distinguishes queued requests, stalled requests, active warnings,
102
116
  and historical warnings. A retry can still return a partial result when the
103
117
  platform keeps returning empty pages. CLI: `pyxcom post-comments ID --output-dir
@@ -0,0 +1,94 @@
1
+ # Verifying missing comment ancestors
2
+
3
+ X can return a child reply while omitting its parent. A saved `post_comments`
4
+ collection keeps such records in its private pending state, rather than assigning
5
+ them a guessed depth. `XClient.save_comment_ancestry()` verifies those parent chains
6
+ with bounded single-post lookups and writes a separate supplement.
7
+
8
+ ```python
9
+ from pyxcom import XClient
10
+
11
+ # Sign in manually before running. Select the original collection's observer.
12
+ with XClient(profile="Default") as client:
13
+ report = client.save_comment_ancestry(
14
+ "data/conversation", "data/conversation_ancestry", lookup_limit=20,
15
+ )
16
+ print(report["resolved_records"], report["unresolved_records"])
17
+ ```
18
+
19
+ The source must pass retained-record and checkpoint integrity verification and
20
+ must be a `post_comments` collection. Both scopes are locked; overlapping paths,
21
+ existing destinations and symlink source contents are rejected. Source files,
22
+ cursors, roles, counts and partial status remain unchanged.
23
+
24
+ ## Lookup rules
25
+
26
+ Only a record's explicit `in_reply_to_id` is requested. Each missing ID is looked
27
+ up once per invocation, including failed lookups, and shared ancestors reuse the
28
+ result. The returned ID and conversation must agree with the source. A chain is
29
+ resolved only when it reaches the exact requested seed, without a cycle. Other
30
+ branches of a reply-seeded conversation are not admitted.
31
+
32
+ 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
+ the manifest. No observer is inferred retroactively and no account rotation occurs.
36
+
37
+ `lookup_limit` is a nonnegative integer limiting single-post lookup calls, not
38
+ HTTP attempts. Identity checks, route discovery and the existing transport's
39
+ retries can add HTTP attempts. Zero makes no network calls. Calls respect client
40
+ delay; a rate limit or request error stops further parent lookups in that run.
41
+
42
+ ## Outputs
43
+
44
+ | File | Contents |
45
+ | --- | --- |
46
+ | `resolution.csv` | One row per original pending record, verified links, depth and scope flag |
47
+ | `ancestor_posts.jsonl` | Newly fetched parents, all `observation_role="context"` |
48
+ | `pending_posts.jsonl` | Copy of the original pending records |
49
+ | `raw/<parent_id>.json` | Credential-free successful lookup payloads, including unavailable results |
50
+ | `manifest.json` | Source and supplement hashes, caps, counts, observer checks and source completeness |
51
+
52
+ `seed_post_id` is the requested collection seed; `root_post_id` is the platform
53
+ conversation root. `chain_ids` lists verified retained IDs from the pending record
54
+ back toward the seed; an unresolved chain does not include the unreturned parent.
55
+ `seed_relative_depth` counts links to the seed and is blank until that chain is
56
+ resolved. `depth` counts links to the platform root and also requires retained
57
+ evidence between a reply seed and that root; otherwise it stays blank. This method
58
+ does not make extra requests above a reply seed just to fill platform depth.
59
+
60
+ `within_requested_bounds` checks the pending record's verified seed-relative depth
61
+ and the original UTC date bounds (inclusive `since`, exclusive `until`). It is blank
62
+ for unresolved chains. A true flag is not automatic sample admission: supplement
63
+ records never alter the original `posts.csv` or `comments.csv`.
64
+
65
+ Statuses include `resolved`, `lookup_budget`, `parent_not_returned`, `rate_limited`,
66
+ `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.
70
+
71
+ 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
74
+ the original source and compare dated supplements explicitly. The manifest's
75
+ `collection_complete` and `source_collection_reason` copy the original collection's
76
+ status, even if every pending chain has now been resolved.
77
+
78
+ ## Recorded validation
79
+
80
+ On October 13, 2026, two retained personal-agent discussion trees contained three
81
+ pending replies. With their original authenticated observers, three parent lookup
82
+ calls resolved two chains in the first tree; the second tree's parent did not
83
+ return a parseable record. Source file inventories and hashes were unchanged.
84
+
85
+ The resolved replies had depths two and four. The source depth bound was three,
86
+ so only the depth-two record passed the depth/date scope flag. This illustrates why
87
+ resolving ancestry and admitting a reply to a research sample are separate steps.
88
+ Both original collections remained partial. These are recorded results, not a
89
+ coverage benchmark; later requests may return different content.
90
+
91
+ Eighteen focused offline regression tests cover exact chains, shared parents,
92
+ reply-seed boundaries, cycles, budgets, source integrity, identity mismatch,
93
+ malformed responses and protected content. The complete suite contained 454
94
+ passing tests at this release.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pyXcom"
7
- version = "1.2.1"
7
+ version = "1.3.0"
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.1"
53
+ __version__ = "1.3.0"
@@ -0,0 +1,286 @@
1
+ """Copy-only, bounded verification of missing comment ancestors."""
2
+
3
+ import csv
4
+ import json
5
+ import time
6
+ from contextlib import ExitStack
7
+ from dataclasses import replace
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ from ._persistence import append_jsonl, atomic_json, read_jsonl
12
+ from ._pool_locks import ScopeLock
13
+ from .errors import IntegrityError, ParseError, PyXcomError, RateLimitError
14
+ from .integrity import assess_recovery
15
+ from .models import Post
16
+ from .parse import single_post
17
+ from .recovery import _files
18
+ from .transport import now_utc
19
+
20
+ if TYPE_CHECKING:
21
+ from .client import XClient
22
+
23
+
24
+ def _directory(value: str | Path) -> Path:
25
+ path = Path(value).expanduser()
26
+ if path.is_symlink():
27
+ raise ValueError("Ancestry directory must not be a symbolic link")
28
+ return path.resolve()
29
+
30
+
31
+ def _mapping(value) -> dict:
32
+ return value if isinstance(value, dict) else {}
33
+
34
+
35
+ def _protected(payload: dict) -> bool:
36
+ result = _mapping(
37
+ _mapping(_mapping(payload.get("data")).get("tweetResult")).get("result")
38
+ )
39
+ tweet = _mapping(result.get("tweet", result))
40
+ user = _mapping(
41
+ _mapping(_mapping(tweet.get("core")).get("user_results")).get("result")
42
+ )
43
+ return any(
44
+ _mapping(user.get(key)).get("protected") is True
45
+ for key in ("privacy", "legacy")
46
+ )
47
+
48
+
49
+ def save_comment_ancestry(
50
+ client: "XClient",
51
+ source_dir: str | Path,
52
+ output_dir: str | Path,
53
+ *,
54
+ lookup_limit: int = 20,
55
+ ) -> dict:
56
+ """Verify exact parent chains without admitting records to the original sample.
57
+
58
+ The cap counts single-post lookup calls, not transport retry attempts. A new
59
+ supplement is required for every run; source files are read-only throughout.
60
+ """
61
+ if (
62
+ isinstance(lookup_limit, bool)
63
+ or not isinstance(lookup_limit, int)
64
+ or lookup_limit < 0
65
+ ):
66
+ raise ValueError("lookup_limit must be a nonnegative integer")
67
+ source, target = _directory(source_dir), _directory(output_dir)
68
+ if source == target or source in target.parents or target in source.parents:
69
+ raise ValueError("Source and supplement scopes must not overlap")
70
+ with ExitStack() as stack:
71
+ for scope in sorted((source, target)):
72
+ stack.enter_context(ScopeLock(scope))
73
+ if target.exists():
74
+ raise FileExistsError("Ancestry supplement already exists")
75
+ assessment = assess_recovery(source)
76
+ if not assessment["allowed"] or assessment["record_integrity"] != "verified":
77
+ raise IntegrityError(
78
+ "Source collection did not pass integrity verification"
79
+ )
80
+ query = assessment["query"]
81
+ if query.get("kind") != "post_comments":
82
+ raise ValueError(
83
+ "Ancestry verification requires a post_comments collection"
84
+ )
85
+ state = json.loads((source / ".pyxcom/state.json").read_text(encoding="utf-8"))
86
+ conversation = state["conversation"]
87
+ pending = conversation.get("pending", {})
88
+ posts = {p["id"]: Post(**p) for p in read_jsonl(source / ".pyxcom/posts.jsonl")}
89
+ posts.update({key: Post(**value) for key, value in pending.items()})
90
+ root = str(query["root_post_id"])
91
+ 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")
99
+ verified_observer = None
100
+ fetched: dict[str, Post] = {}
101
+ failures: dict[str, str] = {}
102
+ raw_responses: dict[str, dict] = {}
103
+ calls = 0
104
+ stop_reason = None
105
+
106
+ def lookup(identifier: str) -> tuple[Post | None, str | None]:
107
+ nonlocal calls, stop_reason, verified_observer
108
+ if identifier in failures:
109
+ return None, failures[identifier]
110
+ if stop_reason:
111
+ return None, stop_reason
112
+ if calls >= lookup_limit:
113
+ return None, "lookup_budget"
114
+ if verified_observer is None:
115
+ verified_observer = str(client.check_login_identity())
116
+ if expected_observer is not None and verified_observer != str(
117
+ expected_observer
118
+ ):
119
+ raise ValueError(
120
+ "Ancestry lookup observer differs from source observer"
121
+ )
122
+ if calls:
123
+ time.sleep(client.delay)
124
+ calls += 1
125
+ payload = None
126
+ try:
127
+ payload = client.get_raw_post(identifier)
128
+ if _protected(payload):
129
+ failures[identifier] = "protected_parent"
130
+ return None, failures[identifier]
131
+ raw_responses[identifier] = payload
132
+ try:
133
+ post = single_post(payload, captured_at_utc=now_utc())
134
+ except (ParseError, ValueError, TypeError, KeyError, AttributeError):
135
+ failures[identifier] = "parent_not_returned"
136
+ return None, failures[identifier]
137
+ if post.id != identifier:
138
+ failures[identifier] = "identity_conflict"
139
+ elif post.conversation_id != conversation_id or post.reposted_post_id:
140
+ failures[identifier] = "conversation_conflict"
141
+ else:
142
+ post = replace(
143
+ post, observation_role="context", discovery_source="x_ancestor"
144
+ )
145
+ posts[identifier] = fetched[identifier] = post
146
+ return post, None
147
+ except ParseError:
148
+ stop_reason = failures[identifier] = "request_error"
149
+ except RateLimitError:
150
+ stop_reason = failures[identifier] = "rate_limited"
151
+ except PyXcomError:
152
+ stop_reason = failures[identifier] = "request_error"
153
+ return None, failures[identifier]
154
+
155
+ def platform_depth(seed_depth: int | None) -> int | None:
156
+ if seed_depth is None:
157
+ return None
158
+ current = posts[root]
159
+ seen = {root}
160
+ offset = 0
161
+ while current.id != conversation_id:
162
+ parent = posts.get(current.in_reply_to_id or "")
163
+ if (
164
+ parent is None
165
+ or parent.id in seen
166
+ or parent.conversation_id != conversation_id
167
+ ):
168
+ return None
169
+ seen.add(parent.id)
170
+ offset += 1
171
+ current = parent
172
+ return seed_depth + offset
173
+
174
+ rows = []
175
+ for identifier, value in pending.items():
176
+ post = posts[identifier]
177
+ chain = [identifier]
178
+ current = post
179
+ status = "resolved"
180
+ while current.id != root:
181
+ if (
182
+ current.conversation_id != conversation_id
183
+ or current.reposted_post_id
184
+ ):
185
+ status = "conversation_conflict"
186
+ break
187
+ parent_id = current.in_reply_to_id
188
+ if parent_id is None or (
189
+ parent_id == conversation_id and conversation_id != root
190
+ ):
191
+ status = "outside_seed_branch"
192
+ break
193
+ if parent_id in chain:
194
+ status = "cycle"
195
+ break
196
+ parent = posts.get(parent_id)
197
+ if parent is None:
198
+ parent, failure = lookup(parent_id)
199
+ if parent is None:
200
+ status = failure or "parent_not_returned"
201
+ break
202
+ if parent.conversation_id != conversation_id or parent.reposted_post_id:
203
+ status = "conversation_conflict"
204
+ break
205
+ chain.append(parent_id)
206
+ current = parent
207
+ seed_depth = len(chain) - 1 if status == "resolved" else None
208
+ within = None
209
+ if seed_depth is not None:
210
+ day = post.created_at_utc[:10]
211
+ within = (
212
+ seed_depth <= query["max_depth"]
213
+ and (not query.get("since") or day >= query["since"])
214
+ and (not query.get("until") or day < query["until"])
215
+ )
216
+ rows.append(
217
+ {
218
+ "post_id": identifier,
219
+ "root_post_id": conversation_id,
220
+ "seed_post_id": root,
221
+ "parent_post_id": value.get("in_reply_to_id"),
222
+ "ancestry_status": status,
223
+ "depth": platform_depth(seed_depth),
224
+ "seed_relative_depth": seed_depth,
225
+ "chain_ids": json.dumps(chain),
226
+ "within_requested_bounds": within,
227
+ }
228
+ )
229
+ if _files(source) != assessment["input_sha256"]:
230
+ 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
+ counts = {
260
+ status: sum(r["ancestry_status"] == status for r in rows)
261
+ for status in sorted({r["ancestry_status"] for r in rows})
262
+ }
263
+ from . import __version__
264
+
265
+ report = {
266
+ "schema": "pyxcom.comment-ancestry/1",
267
+ "package_version": __version__,
268
+ "captured_at_utc": now_utc(),
269
+ "query": query,
270
+ "source_sha256": assessment["input_sha256"],
271
+ "lookup_limit": lookup_limit,
272
+ "lookup_calls": calls,
273
+ "stop_reason": stop_reason,
274
+ "identity_verified": verified_observer is not None,
275
+ "source_observer_bound": expected_observer is not None
276
+ and verified_observer is not None,
277
+ "status_counts": counts,
278
+ "resolved_records": counts.get("resolved", 0),
279
+ "unresolved_records": len(rows) - counts.get("resolved", 0),
280
+ "context_records": len(fetched),
281
+ "collection_complete": assessment["complete"],
282
+ "source_collection_reason": assessment["reason"],
283
+ "files_sha256": _files(target),
284
+ }
285
+ atomic_json(target / "manifest.json", report)
286
+ return report
@@ -957,6 +957,24 @@ class XClient(DiscoveryMixin, NetworkMixin):
957
957
  )
958
958
  )
959
959
 
960
+ def save_comment_ancestry(
961
+ self,
962
+ source_dir: str | Path,
963
+ output_dir: str | Path,
964
+ *,
965
+ lookup_limit: int = 20,
966
+ ) -> dict:
967
+ """Save a separate, bounded supplement verifying missing comment parents.
968
+
969
+ Original collection files and sampling roles are preserved. The cap counts
970
+ single-post lookup calls; transport retries can make more HTTP attempts.
971
+ """
972
+ from .ancestry import save_comment_ancestry
973
+
974
+ return save_comment_ancestry(
975
+ self, source_dir, output_dir, lookup_limit=lookup_limit
976
+ )
977
+
960
978
  @guarded_collection
961
979
  def save_post_comments(
962
980
  self,
@@ -0,0 +1,294 @@
1
+ import csv
2
+ import json
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+ from unittest.mock import Mock
7
+
8
+ from test_comments import ROOT, A, B, C, client, page, tweet
9
+ from pyxcom import IntegrityError, RateLimitError
10
+ from pyxcom.errors import ParseError
11
+
12
+
13
+ def lookup_payload(post):
14
+ return {"data": {"tweetResult": {"result": post}}}
15
+
16
+
17
+ class AncestryTests(unittest.TestCase):
18
+ def make_source(self, tmp, posts):
19
+ source = Path(tmp) / "source"
20
+ c = client(page(*posts))
21
+ c.save_post_comments(ROOT, source, max_depth=3, max_pages=2)
22
+ return c, source
23
+
24
+ def files(self, source):
25
+ return {
26
+ str(p.relative_to(source)): p.read_bytes()
27
+ for p in source.rglob("*")
28
+ if p.is_file()
29
+ }
30
+
31
+ def rows(self, destination):
32
+ with (destination / "resolution.csv").open(
33
+ newline="", encoding="utf-8-sig"
34
+ ) as f:
35
+ return list(csv.DictReader(f))
36
+
37
+ def test_exact_chain_resolves_depth_and_preserves_source(self):
38
+ with tempfile.TemporaryDirectory() as tmp:
39
+ c, source = self.make_source(tmp, [tweet(C, B)])
40
+ c._observer_id = "100"
41
+ c.check_login_identity = Mock(return_value="100")
42
+ c.get_raw_post = Mock(
43
+ side_effect=[
44
+ lookup_payload(tweet(B, A)),
45
+ lookup_payload(tweet(A, ROOT)),
46
+ ]
47
+ )
48
+ before = self.files(source)
49
+ target = Path(tmp) / "resolved"
50
+ report = c.save_comment_ancestry(source, target, lookup_limit=2)
51
+ self.assertEqual(self.files(source), before)
52
+ self.assertEqual([x.args[0] for x in c.get_raw_post.call_args_list], [B, A])
53
+ row = self.rows(target)[0]
54
+ self.assertEqual((row["ancestry_status"], row["depth"]), ("resolved", "3"))
55
+ self.assertEqual(report["resolved_records"], 1)
56
+ contexts = [
57
+ json.loads(line)
58
+ for line in (target / "ancestor_posts.jsonl").read_text().split("\n")
59
+ if line
60
+ ]
61
+ self.assertEqual({p["observation_role"] for p in contexts}, {"context"})
62
+ self.assertFalse(report["collection_complete"])
63
+
64
+ def test_zero_budget_does_not_lookup_or_guess_depth(self):
65
+ with tempfile.TemporaryDirectory() as tmp:
66
+ c, source = self.make_source(tmp, [tweet(B, A)])
67
+ c.get_raw_post = Mock()
68
+ c.check_login_identity = Mock()
69
+ target = Path(tmp) / "resolved"
70
+ report = c.save_comment_ancestry(source, target, lookup_limit=0)
71
+ c.get_raw_post.assert_not_called()
72
+ c.check_login_identity.assert_not_called()
73
+ row = self.rows(target)[0]
74
+ self.assertEqual(row["ancestry_status"], "lookup_budget")
75
+ self.assertEqual(row["depth"], "")
76
+ self.assertEqual(report["lookup_calls"], 0)
77
+
78
+ def test_unreturned_parent_stays_unresolved(self):
79
+ with tempfile.TemporaryDirectory() as tmp:
80
+ c, source = self.make_source(tmp, [tweet(B, A)])
81
+ c.check_login_identity = Mock(return_value="100")
82
+ c.get_raw_post = Mock(return_value={"data": {"tweetResult": {}}})
83
+ target = Path(tmp) / "resolved"
84
+ c.save_comment_ancestry(source, target, lookup_limit=1)
85
+ row = self.rows(target)[0]
86
+ self.assertEqual(row["ancestry_status"], "parent_not_returned")
87
+ self.assertEqual(row["depth"], "")
88
+
89
+ def test_rate_limit_stops_all_further_parent_lookups(self):
90
+ with tempfile.TemporaryDirectory() as tmp:
91
+ c, source = self.make_source(
92
+ tmp, [tweet(B, A), tweet(C, "1974042999999999999")]
93
+ )
94
+ c.check_login_identity = Mock(return_value="100")
95
+ c.get_raw_post = Mock(side_effect=RateLimitError("limited"))
96
+ report = c.save_comment_ancestry(
97
+ source, Path(tmp) / "resolved", lookup_limit=10
98
+ )
99
+ self.assertEqual(c.get_raw_post.call_count, 1)
100
+ self.assertEqual(report["stop_reason"], "rate_limited")
101
+
102
+ def test_wrong_conversation_is_not_admitted(self):
103
+ with tempfile.TemporaryDirectory() as tmp:
104
+ c, source = self.make_source(tmp, [tweet(B, A)])
105
+ c.check_login_identity = Mock(return_value="100")
106
+ c.get_raw_post = Mock(
107
+ return_value=lookup_payload(tweet(A, ROOT, root="1974042999999999999"))
108
+ )
109
+ target = Path(tmp) / "resolved"
110
+ c.save_comment_ancestry(source, target, lookup_limit=1)
111
+ self.assertEqual(
112
+ self.rows(target)[0]["ancestry_status"], "conversation_conflict"
113
+ )
114
+ self.assertEqual((target / "ancestor_posts.jsonl").read_text(), "")
115
+
116
+ def test_observer_mismatch_rejects_before_parent_lookup(self):
117
+ with tempfile.TemporaryDirectory() as tmp:
118
+ c, source = self.make_source(tmp, [tweet(B, A)])
119
+ (source / ".pyxcom/pool-owner.json").write_text(
120
+ json.dumps({"observer_id": "200"})
121
+ )
122
+ c.check_login_identity = Mock(return_value="100")
123
+ c.get_raw_post = Mock()
124
+ with self.assertRaises(ValueError):
125
+ c.save_comment_ancestry(source, Path(tmp) / "resolved")
126
+ c.get_raw_post.assert_not_called()
127
+
128
+ def test_shared_missing_parent_is_fetched_once(self):
129
+ with tempfile.TemporaryDirectory() as tmp:
130
+ c, source = self.make_source(tmp, [tweet(B, A), tweet(C, A)])
131
+ c.check_login_identity = Mock(return_value="100")
132
+ c.get_raw_post = Mock(return_value=lookup_payload(tweet(A, ROOT)))
133
+ target = Path(tmp) / "resolved"
134
+ report = c.save_comment_ancestry(source, target)
135
+ self.assertEqual(report["resolved_records"], 2)
136
+ self.assertEqual(c.get_raw_post.call_count, 1)
137
+ self.assertEqual({r["depth"] for r in self.rows(target)}, {"2"})
138
+
139
+ def test_cycle_does_not_produce_depth(self):
140
+ with tempfile.TemporaryDirectory() as tmp:
141
+ c, source = self.make_source(tmp, [tweet(B, A)])
142
+ c.check_login_identity = Mock(return_value="100")
143
+ c.get_raw_post = Mock(return_value=lookup_payload(tweet(A, B)))
144
+ target = Path(tmp) / "resolved"
145
+ c.save_comment_ancestry(source, target)
146
+ row = self.rows(target)[0]
147
+ self.assertEqual((row["ancestry_status"], row["depth"]), ("cycle", ""))
148
+
149
+ def test_chain_budget_retains_context_without_claiming_resolution(self):
150
+ with tempfile.TemporaryDirectory() as tmp:
151
+ c, source = self.make_source(tmp, [tweet(C, B)])
152
+ c.check_login_identity = Mock(return_value="100")
153
+ c.get_raw_post = Mock(return_value=lookup_payload(tweet(B, A)))
154
+ target = Path(tmp) / "resolved"
155
+ report = c.save_comment_ancestry(source, target, lookup_limit=1)
156
+ self.assertEqual(report["context_records"], 1)
157
+ row = self.rows(target)[0]
158
+ self.assertEqual(
159
+ (row["ancestry_status"], row["depth"]), ("lookup_budget", "")
160
+ )
161
+ self.assertEqual(json.loads(row["chain_ids"]), [C, B])
162
+
163
+ def test_wrong_id_is_not_retained_as_context(self):
164
+ with tempfile.TemporaryDirectory() as tmp:
165
+ c, source = self.make_source(tmp, [tweet(B, A)])
166
+ c.check_login_identity = Mock(return_value="100")
167
+ c.get_raw_post = Mock(return_value=lookup_payload(tweet(C, ROOT)))
168
+ target = Path(tmp) / "resolved"
169
+ report = c.save_comment_ancestry(source, target)
170
+ self.assertEqual(
171
+ self.rows(target)[0]["ancestry_status"], "identity_conflict"
172
+ )
173
+ self.assertEqual(report["context_records"], 0)
174
+
175
+ def test_protected_parent_content_is_not_saved(self):
176
+ with tempfile.TemporaryDirectory() as tmp:
177
+ c, source = self.make_source(tmp, [tweet(B, A)])
178
+ parent = tweet(A, ROOT)
179
+ parent["core"]["user_results"]["result"]["privacy"] = {"protected": True}
180
+ c.check_login_identity = Mock(return_value="100")
181
+ c.get_raw_post = Mock(return_value=lookup_payload(parent))
182
+ target = Path(tmp) / "resolved"
183
+ report = c.save_comment_ancestry(source, target)
184
+ self.assertEqual(
185
+ self.rows(target)[0]["ancestry_status"], "protected_parent"
186
+ )
187
+ self.assertEqual(report["context_records"], 0)
188
+ self.assertEqual(list((target / "raw").iterdir()), [])
189
+
190
+ def test_lookup_parse_error_stops_without_unbound_payload(self):
191
+ with tempfile.TemporaryDirectory() as tmp:
192
+ c, source = self.make_source(tmp, [tweet(B, A)])
193
+ c.check_login_identity = Mock(return_value="100")
194
+ c.get_raw_post = Mock(side_effect=ParseError("changed interface"))
195
+ report = c.save_comment_ancestry(source, Path(tmp) / "resolved")
196
+ self.assertEqual(report["stop_reason"], "request_error")
197
+
198
+ def test_protected_unparseable_parent_is_not_archived(self):
199
+ with tempfile.TemporaryDirectory() as tmp:
200
+ c, source = self.make_source(tmp, [tweet(B, A)])
201
+ parent = tweet(A, ROOT)
202
+ parent["core"]["user_results"]["result"]["legacy"] = {"protected": True}
203
+ del parent["legacy"]["created_at"]
204
+ c.check_login_identity = Mock(return_value="100")
205
+ c.get_raw_post = Mock(return_value=lookup_payload(parent))
206
+ target = Path(tmp) / "resolved"
207
+ c.save_comment_ancestry(source, target)
208
+ self.assertEqual(
209
+ self.rows(target)[0]["ancestry_status"], "protected_parent"
210
+ )
211
+ self.assertEqual(list((target / "raw").iterdir()), [])
212
+
213
+ def test_malformed_second_response_preserves_first_resolution(self):
214
+ with tempfile.TemporaryDirectory() as tmp:
215
+ c, source = self.make_source(
216
+ tmp, [tweet(B, A), tweet(C, "1974042999999999999")]
217
+ )
218
+ c.check_login_identity = Mock(return_value="100")
219
+ c.get_raw_post = Mock(
220
+ side_effect=[lookup_payload(tweet(A, ROOT)), {"data": None}]
221
+ )
222
+ target = Path(tmp) / "resolved"
223
+ report = c.save_comment_ancestry(source, target)
224
+ self.assertEqual(report["resolved_records"], 1)
225
+ self.assertEqual(report["status_counts"]["parent_not_returned"], 1)
226
+ self.assertEqual(report["context_records"], 1)
227
+
228
+ def test_paths_and_corruption_reject_before_network(self):
229
+ with tempfile.TemporaryDirectory() as tmp:
230
+ c, source = self.make_source(tmp, [tweet(B, A)])
231
+ c.check_login_identity = Mock()
232
+ for target in (source, source / "child", source.parent):
233
+ with self.assertRaises(ValueError):
234
+ c.save_comment_ancestry(source, target)
235
+ target = Path(tmp) / "existing"
236
+ target.mkdir()
237
+ with self.assertRaises(FileExistsError):
238
+ c.save_comment_ancestry(source, target)
239
+ (source / ".pyxcom/posts.jsonl").write_text("{}\n")
240
+ with self.assertRaises(IntegrityError):
241
+ c.save_comment_ancestry(source, Path(tmp) / "resolved")
242
+ c.check_login_identity.assert_not_called()
243
+
244
+ def test_depth_bounds_are_reported_without_sample_admission(self):
245
+ with tempfile.TemporaryDirectory() as tmp:
246
+ source = Path(tmp) / "source"
247
+ c = client(page(tweet(C, B)))
248
+ c.save_post_comments(ROOT, source, max_depth=1)
249
+ c.check_login_identity = Mock(return_value="100")
250
+ c.get_raw_post = Mock(
251
+ side_effect=[
252
+ lookup_payload(tweet(B, A)),
253
+ lookup_payload(tweet(A, ROOT)),
254
+ ]
255
+ )
256
+ target = Path(tmp) / "resolved"
257
+ c.save_comment_ancestry(source, target)
258
+ row = self.rows(target)[0]
259
+ self.assertEqual(row["depth"], "3")
260
+ self.assertEqual(row["within_requested_bounds"], "False")
261
+
262
+ def test_reply_seed_does_not_admit_other_branch(self):
263
+ with tempfile.TemporaryDirectory() as tmp:
264
+ source = Path(tmp) / "source"
265
+ c = client(page(tweet(C, B)))
266
+ from pyxcom.parse import parse_post
267
+
268
+ c.get_post = Mock(return_value=parse_post(tweet(A, ROOT)))
269
+ c.save_post_comments(A, source, max_depth=3)
270
+ c.check_login_identity = Mock(return_value="100")
271
+ c.get_raw_post = Mock(return_value=lookup_payload(tweet(B, ROOT)))
272
+ target = Path(tmp) / "resolved"
273
+ c.save_comment_ancestry(source, target)
274
+ self.assertEqual(
275
+ self.rows(target)[0]["ancestry_status"], "outside_seed_branch"
276
+ )
277
+
278
+ def test_reply_seed_depth_is_relative_to_seed(self):
279
+ with tempfile.TemporaryDirectory() as tmp:
280
+ source = Path(tmp) / "source"
281
+ c = client(page(tweet(C, B)))
282
+ from pyxcom.parse import parse_post
283
+
284
+ c.get_post = Mock(return_value=parse_post(tweet(A, ROOT)))
285
+ c.save_post_comments(A, source, max_depth=3)
286
+ c.check_login_identity = Mock(return_value="100")
287
+ c.get_raw_post = Mock(return_value=lookup_payload(tweet(B, A)))
288
+ target = Path(tmp) / "resolved"
289
+ c.save_comment_ancestry(source, target)
290
+ row = self.rows(target)[0]
291
+ self.assertEqual(row["seed_relative_depth"], "2")
292
+ self.assertEqual(row["root_post_id"], ROOT)
293
+ self.assertEqual(row["seed_post_id"], A)
294
+ self.assertEqual(row["depth"], "")
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