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.
- {pyxcom-1.2.1 → pyxcom-1.3.0}/CHANGELOG.md +13 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/CITATION.cff +1 -1
- {pyxcom-1.2.1 → pyxcom-1.3.0}/PKG-INFO +19 -1
- {pyxcom-1.2.1 → pyxcom-1.3.0}/README.md +18 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/api.md +14 -0
- pyxcom-1.3.0/docs/comment-ancestry.md +94 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/pyproject.toml +1 -1
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/__init__.py +1 -1
- pyxcom-1.3.0/src/pyxcom/ancestry.py +286 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/client.py +18 -0
- pyxcom-1.3.0/tests/test_comment_ancestry.py +294 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/.gitignore +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/LICENSE +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/NOTICE +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/LIVE_RESULTS.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/README.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/REPLAY_RESULTS.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/STORAGE_RESULTS.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/common.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/environment-lock.txt +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/live_arm.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/live_compare.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/live_pool.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/metric-batch-results.json +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/metric_batch.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/normalize.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/pool_summary.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/replay.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/benchmarks/requirements.txt +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/datasets.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/discovery-content.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/pool.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/profile-observations.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/publishing.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/recovery.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/release-1.0-plan.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/release-1.0-validation.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/stability-plan.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/stability.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/timeline-pagination-plan.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/docs/timeline-pagination.md +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/examples/basic.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/examples/research_networks.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/_persistence.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/_pool_locks.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/assets.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/auth.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/batch.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/cli.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/comments.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/datasets.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/discovery.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/errors.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/integrity.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/layout.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/models.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/networks.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/parse.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/pool.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/profiles.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/provenance.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/recovery.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/schema.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/search.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/storage.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/tables.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/transport.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/validate.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/src/pyxcom/validate_tables.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_api_interruption.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_assets.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_auth.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_batch_report.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_benchmark_harness.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_benchmark_replay.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_capability_cli.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_capability_transport.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_comment_stalls.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_comment_transport.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_comments.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_datasets.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_direct_search.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_discovery.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_discovery_content.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_discovery_pagination.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_integrity.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_layout.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_metric_batch.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_networks.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_observations.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_parsing.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_persistence.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_pool.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_pool_cli.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_pool_locks.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_pool_provenance.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_profile_batch.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_profile_history.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_project_metadata.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_public_api.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_recovery.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_recovery_cli.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_relational_integration.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_request_control.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_schema.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_search_scope.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_search_storage.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_session_identity.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_tables.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_timeline_pagination.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_transport.py +0 -0
- {pyxcom-1.2.1 → pyxcom-1.3.0}/tests/test_validate_date_scope.py +0 -0
- {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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pyXcom
|
|
3
|
-
Version: 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.
|
|
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"
|
|
@@ -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
|
|
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
|