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