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