django-pbn-client 0.1.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.
- django_pbn_client-0.1.0/.github/workflows/ci.yml +41 -0
- django_pbn_client-0.1.0/.gitignore +19 -0
- django_pbn_client-0.1.0/.pre-commit-config.yaml +18 -0
- django_pbn_client-0.1.0/LICENSE +21 -0
- django_pbn_client-0.1.0/PKG-INFO +102 -0
- django_pbn_client-0.1.0/README.md +73 -0
- django_pbn_client-0.1.0/pyproject.toml +70 -0
- django_pbn_client-0.1.0/src/django_pbn_client/__init__.py +33 -0
- django_pbn_client-0.1.0/src/django_pbn_client/models.py +106 -0
- django_pbn_client-0.1.0/src/django_pbn_client/pages.py +172 -0
- django_pbn_client-0.1.0/src/django_pbn_client/persistence.py +116 -0
- django_pbn_client-0.1.0/tests/package_test_settings.py +10 -0
- django_pbn_client-0.1.0/tests/test_django_pbn_client_models.py +109 -0
- django_pbn_client-0.1.0/tests/test_django_pbn_client_pages.py +115 -0
- django_pbn_client-0.1.0/tests/test_django_pbn_client_persistence.py +116 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main, master]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
concurrency:
|
|
12
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
13
|
+
cancel-in-progress: true
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
test:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
strategy:
|
|
19
|
+
fail-fast: false
|
|
20
|
+
matrix:
|
|
21
|
+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
22
|
+
steps:
|
|
23
|
+
- name: Check out django-pbn-client
|
|
24
|
+
uses: actions/checkout@v4
|
|
25
|
+
|
|
26
|
+
# django-pbn-client declares `pbn-client` as a dependency. Until that
|
|
27
|
+
# package is published to PyPI, the [tool.uv.sources] override resolves
|
|
28
|
+
# it from a sibling checkout, so CI clones it next to this repo.
|
|
29
|
+
# (actions/checkout cannot target a path outside the workspace, so we
|
|
30
|
+
# clone the sibling directly.) Remove this step and the
|
|
31
|
+
# [tool.uv.sources] entry once pbn-client is available on PyPI.
|
|
32
|
+
- name: Check out sibling pbn-client
|
|
33
|
+
run: git clone --depth 1 https://github.com/iplweb/pbn-client.git ../pbn-client
|
|
34
|
+
|
|
35
|
+
- name: Install uv
|
|
36
|
+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
|
37
|
+
with:
|
|
38
|
+
python-version: ${{ matrix.python-version }}
|
|
39
|
+
|
|
40
|
+
- name: Run tests
|
|
41
|
+
run: uv run --group dev pytest
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
3
|
+
rev: v6.0.0
|
|
4
|
+
hooks:
|
|
5
|
+
- id: trailing-whitespace
|
|
6
|
+
- id: end-of-file-fixer
|
|
7
|
+
- id: check-yaml
|
|
8
|
+
- id: check-toml
|
|
9
|
+
- id: check-added-large-files
|
|
10
|
+
- id: check-merge-conflict
|
|
11
|
+
- id: detect-private-key
|
|
12
|
+
|
|
13
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
14
|
+
rev: v0.15.21
|
|
15
|
+
hooks:
|
|
16
|
+
- id: ruff-check
|
|
17
|
+
args: [--fix]
|
|
18
|
+
- id: ruff-format
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2017-2026 Michal Pasternak
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-pbn-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Django persistence helpers for data downloaded from PBN
|
|
5
|
+
Project-URL: Homepage, https://github.com/iplweb/django-pbn-client
|
|
6
|
+
Project-URL: Repository, https://github.com/iplweb/django-pbn-client
|
|
7
|
+
Project-URL: Issues, https://github.com/iplweb/django-pbn-client/issues
|
|
8
|
+
Author-email: Michał Pasternak <michal.dtz@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: bibliography,django,pbn,persistence,polon
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Framework :: Django :: 5.2
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Requires-Python: <3.15,>=3.10
|
|
26
|
+
Requires-Dist: django<5.3,>=5.2
|
|
27
|
+
Requires-Dist: pbn-client<0.2,>=0.1.0
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# django-pbn-client
|
|
31
|
+
|
|
32
|
+
[](https://github.com/iplweb/django-pbn-client/actions/workflows/ci.yml)
|
|
33
|
+
[](https://opensource.org/licenses/MIT)
|
|
34
|
+
[](https://www.python.org/)
|
|
35
|
+
|
|
36
|
+
Reusable Django models and persistence services for data downloaded from the
|
|
37
|
+
Polish Bibliography Network (PBN).
|
|
38
|
+
|
|
39
|
+
The package deliberately does not define concrete application models or
|
|
40
|
+
migrations. Applications subclass `BasePBNMongoDBModel` and retain ownership
|
|
41
|
+
of their database schema:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from django.db import models
|
|
45
|
+
from django_pbn_client import BasePBNMongoDBModel
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Publication(BasePBNMongoDBModel):
|
|
49
|
+
title = models.TextField(blank=True, default="")
|
|
50
|
+
pull_up_on_save = ["title"]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Downloaded objects can then be stored atomically:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from django_pbn_client import download_pbn_objects, upsert_pbn_object
|
|
57
|
+
|
|
58
|
+
publication = upsert_pbn_object(payload, Publication)
|
|
59
|
+
download_pbn_objects(client.get_publications(), Publication)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`django-pbn-client` depends only on Django and the transport-level
|
|
63
|
+
`pbn-client` package. Progress bars, background jobs, concrete relationships,
|
|
64
|
+
and application-specific integration remain the responsibility of the host
|
|
65
|
+
application.
|
|
66
|
+
|
|
67
|
+
Concurrent page downloads use threads by default. The optional
|
|
68
|
+
`method="processes"` mode uses the POSIX `fork` start method and is therefore
|
|
69
|
+
not available on Windows.
|
|
70
|
+
|
|
71
|
+
## Installation
|
|
72
|
+
|
|
73
|
+
```console
|
|
74
|
+
pip install django-pbn-client
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Development
|
|
78
|
+
|
|
79
|
+
This package depends on the transport-level
|
|
80
|
+
[`pbn-client`](https://github.com/iplweb/pbn-client) package. Until `pbn-client`
|
|
81
|
+
is published to PyPI, a dev-only `[tool.uv.sources]` entry in `pyproject.toml`
|
|
82
|
+
resolves it from a sibling checkout, so clone both repositories side by side:
|
|
83
|
+
|
|
84
|
+
```console
|
|
85
|
+
git clone https://github.com/iplweb/pbn-client.git
|
|
86
|
+
git clone https://github.com/iplweb/django-pbn-client.git
|
|
87
|
+
cd django-pbn-client
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The source override is ignored when building or publishing wheels, so end users
|
|
91
|
+
still get `pbn-client` from PyPI via the version constraint in
|
|
92
|
+
`[project.dependencies]`.
|
|
93
|
+
|
|
94
|
+
Run the standalone package tests with:
|
|
95
|
+
|
|
96
|
+
```console
|
|
97
|
+
uv run --group dev pytest
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
Released under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# django-pbn-client
|
|
2
|
+
|
|
3
|
+
[](https://github.com/iplweb/django-pbn-client/actions/workflows/ci.yml)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://www.python.org/)
|
|
6
|
+
|
|
7
|
+
Reusable Django models and persistence services for data downloaded from the
|
|
8
|
+
Polish Bibliography Network (PBN).
|
|
9
|
+
|
|
10
|
+
The package deliberately does not define concrete application models or
|
|
11
|
+
migrations. Applications subclass `BasePBNMongoDBModel` and retain ownership
|
|
12
|
+
of their database schema:
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from django.db import models
|
|
16
|
+
from django_pbn_client import BasePBNMongoDBModel
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Publication(BasePBNMongoDBModel):
|
|
20
|
+
title = models.TextField(blank=True, default="")
|
|
21
|
+
pull_up_on_save = ["title"]
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Downloaded objects can then be stored atomically:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from django_pbn_client import download_pbn_objects, upsert_pbn_object
|
|
28
|
+
|
|
29
|
+
publication = upsert_pbn_object(payload, Publication)
|
|
30
|
+
download_pbn_objects(client.get_publications(), Publication)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`django-pbn-client` depends only on Django and the transport-level
|
|
34
|
+
`pbn-client` package. Progress bars, background jobs, concrete relationships,
|
|
35
|
+
and application-specific integration remain the responsibility of the host
|
|
36
|
+
application.
|
|
37
|
+
|
|
38
|
+
Concurrent page downloads use threads by default. The optional
|
|
39
|
+
`method="processes"` mode uses the POSIX `fork` start method and is therefore
|
|
40
|
+
not available on Windows.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```console
|
|
45
|
+
pip install django-pbn-client
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Development
|
|
49
|
+
|
|
50
|
+
This package depends on the transport-level
|
|
51
|
+
[`pbn-client`](https://github.com/iplweb/pbn-client) package. Until `pbn-client`
|
|
52
|
+
is published to PyPI, a dev-only `[tool.uv.sources]` entry in `pyproject.toml`
|
|
53
|
+
resolves it from a sibling checkout, so clone both repositories side by side:
|
|
54
|
+
|
|
55
|
+
```console
|
|
56
|
+
git clone https://github.com/iplweb/pbn-client.git
|
|
57
|
+
git clone https://github.com/iplweb/django-pbn-client.git
|
|
58
|
+
cd django-pbn-client
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The source override is ignored when building or publishing wheels, so end users
|
|
62
|
+
still get `pbn-client` from PyPI via the version constraint in
|
|
63
|
+
`[project.dependencies]`.
|
|
64
|
+
|
|
65
|
+
Run the standalone package tests with:
|
|
66
|
+
|
|
67
|
+
```console
|
|
68
|
+
uv run --group dev pytest
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
Released under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "django-pbn-client"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Django persistence helpers for data downloaded from PBN"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10,<3.15"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Michał Pasternak", email = "michal.dtz@gmail.com" },
|
|
15
|
+
]
|
|
16
|
+
keywords = ["django", "pbn", "bibliography", "polon", "persistence"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Framework :: Django",
|
|
20
|
+
"Framework :: Django :: 5.2",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Programming Language :: Python :: 3.13",
|
|
29
|
+
"Programming Language :: Python :: 3.14",
|
|
30
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
31
|
+
]
|
|
32
|
+
dependencies = [
|
|
33
|
+
"Django>=5.2,<5.3",
|
|
34
|
+
"pbn-client>=0.1.0,<0.2",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/iplweb/django-pbn-client"
|
|
39
|
+
Repository = "https://github.com/iplweb/django-pbn-client"
|
|
40
|
+
Issues = "https://github.com/iplweb/django-pbn-client/issues"
|
|
41
|
+
|
|
42
|
+
[dependency-groups]
|
|
43
|
+
dev = [
|
|
44
|
+
"pytest>=9.1.1,<10",
|
|
45
|
+
"pytest-django>=4.9,<5",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
# Dev-only source override. The published `pbn-client` dependency above is what
|
|
49
|
+
# real installs resolve from PyPI once that package is released. Until then this
|
|
50
|
+
# points uv at the sibling checkout so the package is developable and testable
|
|
51
|
+
# standalone. uv ignores [tool.uv.sources] when building/publishing wheels, so
|
|
52
|
+
# the constraint in [project.dependencies] is unaffected for downstream users.
|
|
53
|
+
[tool.uv.sources]
|
|
54
|
+
pbn-client = { path = "../pbn-client", editable = true }
|
|
55
|
+
|
|
56
|
+
[tool.hatch.build.targets.wheel]
|
|
57
|
+
packages = ["src/django_pbn_client"]
|
|
58
|
+
|
|
59
|
+
[tool.pytest.ini_options]
|
|
60
|
+
addopts = "-ra"
|
|
61
|
+
pythonpath = ["src", "tests"]
|
|
62
|
+
DJANGO_SETTINGS_MODULE = "package_test_settings"
|
|
63
|
+
testpaths = ["tests"]
|
|
64
|
+
|
|
65
|
+
[tool.ruff]
|
|
66
|
+
line-length = 88
|
|
67
|
+
target-version = "py310"
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint]
|
|
70
|
+
select = ["E", "F", "I"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Django persistence helpers for data downloaded from PBN."""
|
|
2
|
+
|
|
3
|
+
from django_pbn_client.models import (
|
|
4
|
+
MAX_TEXT_FIELD_LENGTH,
|
|
5
|
+
BasePBNModel,
|
|
6
|
+
BasePBNMongoDBModel,
|
|
7
|
+
)
|
|
8
|
+
from django_pbn_client.pages import (
|
|
9
|
+
ThreadedModelSaver,
|
|
10
|
+
ThreadedMongoDBSaver,
|
|
11
|
+
ThreadedPageGetter,
|
|
12
|
+
download_pages,
|
|
13
|
+
simple_page_getter,
|
|
14
|
+
)
|
|
15
|
+
from django_pbn_client.persistence import (
|
|
16
|
+
download_pbn_objects,
|
|
17
|
+
get_total_count,
|
|
18
|
+
upsert_pbn_object,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"MAX_TEXT_FIELD_LENGTH",
|
|
23
|
+
"BasePBNModel",
|
|
24
|
+
"BasePBNMongoDBModel",
|
|
25
|
+
"ThreadedModelSaver",
|
|
26
|
+
"ThreadedMongoDBSaver",
|
|
27
|
+
"ThreadedPageGetter",
|
|
28
|
+
"download_pages",
|
|
29
|
+
"download_pbn_objects",
|
|
30
|
+
"get_total_count",
|
|
31
|
+
"simple_page_getter",
|
|
32
|
+
"upsert_pbn_object",
|
|
33
|
+
]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Abstract Django models for PBN objects."""
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
|
|
5
|
+
from django.db import models
|
|
6
|
+
from django.utils.functional import cached_property
|
|
7
|
+
|
|
8
|
+
MAX_TEXT_FIELD_LENGTH = 350
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BasePBNModel(models.Model):
|
|
12
|
+
"""Base timestamps shared by locally persisted PBN objects."""
|
|
13
|
+
|
|
14
|
+
created_on = models.DateTimeField(auto_now_add=True)
|
|
15
|
+
last_updated_on = models.DateTimeField(auto_now=True)
|
|
16
|
+
|
|
17
|
+
class Meta:
|
|
18
|
+
abstract = True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BasePBNMongoDBModel(BasePBNModel):
|
|
22
|
+
"""Abstract representation of a versioned object returned by PBN."""
|
|
23
|
+
|
|
24
|
+
mongoId = models.CharField(max_length=32, primary_key=True)
|
|
25
|
+
status = models.CharField(max_length=32, db_index=True, default="")
|
|
26
|
+
verificationLevel = models.CharField(
|
|
27
|
+
max_length=32,
|
|
28
|
+
db_index=True,
|
|
29
|
+
default="",
|
|
30
|
+
)
|
|
31
|
+
verified = models.BooleanField(default=False, db_index=True)
|
|
32
|
+
versions = models.JSONField(default=list)
|
|
33
|
+
|
|
34
|
+
# Field names copied from the current version's ``object`` dictionary.
|
|
35
|
+
pull_up_on_save = None
|
|
36
|
+
|
|
37
|
+
class Meta:
|
|
38
|
+
abstract = True
|
|
39
|
+
|
|
40
|
+
def save(
|
|
41
|
+
self,
|
|
42
|
+
force_insert=False,
|
|
43
|
+
force_update=False,
|
|
44
|
+
using=None,
|
|
45
|
+
update_fields=None,
|
|
46
|
+
):
|
|
47
|
+
if self.pull_up_on_save:
|
|
48
|
+
self._pull_up_on_save()
|
|
49
|
+
return super().save(
|
|
50
|
+
force_insert=force_insert,
|
|
51
|
+
force_update=force_update,
|
|
52
|
+
using=using,
|
|
53
|
+
update_fields=update_fields,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def _pull_up_on_save(self):
|
|
57
|
+
for attr in self.pull_up_on_save:
|
|
58
|
+
pull_up = getattr(self, f"pull_up_{attr}", None)
|
|
59
|
+
if pull_up is not None:
|
|
60
|
+
value = pull_up()
|
|
61
|
+
else:
|
|
62
|
+
value = self.value_or_none("object", attr)
|
|
63
|
+
|
|
64
|
+
if value is not None:
|
|
65
|
+
if isinstance(value, str) and len(value) >= MAX_TEXT_FIELD_LENGTH:
|
|
66
|
+
value = value[:MAX_TEXT_FIELD_LENGTH]
|
|
67
|
+
setattr(self, attr, value)
|
|
68
|
+
|
|
69
|
+
@cached_property
|
|
70
|
+
def current_version(self):
|
|
71
|
+
"""Return the version marked as current, if one exists."""
|
|
72
|
+
if self.versions:
|
|
73
|
+
for element in self.versions:
|
|
74
|
+
if element.get("current"):
|
|
75
|
+
return element
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
def value(self, *path, return_none=False):
|
|
79
|
+
"""Read a nested value from the current version."""
|
|
80
|
+
value = self.current_version
|
|
81
|
+
if value is None:
|
|
82
|
+
warnings.warn(
|
|
83
|
+
f"Model {self.__class__} with id {self.mongoId} "
|
|
84
|
+
"has NO current_version!",
|
|
85
|
+
stacklevel=2,
|
|
86
|
+
)
|
|
87
|
+
if return_none:
|
|
88
|
+
return None
|
|
89
|
+
return "[brak current_version]"
|
|
90
|
+
|
|
91
|
+
for element in path:
|
|
92
|
+
if element in value:
|
|
93
|
+
value = value[element]
|
|
94
|
+
else:
|
|
95
|
+
if return_none:
|
|
96
|
+
return None
|
|
97
|
+
return f"[brak {element}]"
|
|
98
|
+
return value
|
|
99
|
+
|
|
100
|
+
def value_or_none(self, *path):
|
|
101
|
+
"""Read a nested value, returning ``None`` when it is absent."""
|
|
102
|
+
return self.value(*path, return_none=True)
|
|
103
|
+
|
|
104
|
+
def website(self):
|
|
105
|
+
"""Return the website stored in the current object version."""
|
|
106
|
+
return self.value("object", "website")
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Concurrent page download services for PBN iterators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import multiprocessing
|
|
6
|
+
from collections.abc import Callable, Iterable
|
|
7
|
+
from multiprocessing.dummy import Pool as ThreadPool
|
|
8
|
+
from typing import TypeVar
|
|
9
|
+
|
|
10
|
+
from django import setup as django_setup
|
|
11
|
+
from django.db import close_old_connections, connections
|
|
12
|
+
from pbn_client.exceptions import HttpException
|
|
13
|
+
|
|
14
|
+
from django_pbn_client.persistence import upsert_pbn_object
|
|
15
|
+
|
|
16
|
+
PageResult = TypeVar("PageResult")
|
|
17
|
+
PageProgressWrapper = Callable[
|
|
18
|
+
[Iterable[PageResult], int | None, str],
|
|
19
|
+
Iterable[PageResult],
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def simple_page_getter(
|
|
24
|
+
client,
|
|
25
|
+
data,
|
|
26
|
+
repeat_on_failure=False,
|
|
27
|
+
skip_page_on_failure=False,
|
|
28
|
+
):
|
|
29
|
+
"""Yield paginator pages sequentially with optional HTTP 500 retries."""
|
|
30
|
+
del client
|
|
31
|
+
|
|
32
|
+
for page_number in range(data.total_pages):
|
|
33
|
+
while True:
|
|
34
|
+
try:
|
|
35
|
+
yield data.fetch_page(page_number)
|
|
36
|
+
break
|
|
37
|
+
except HttpException as error:
|
|
38
|
+
if skip_page_on_failure:
|
|
39
|
+
break
|
|
40
|
+
if not repeat_on_failure or error.status_code != 500:
|
|
41
|
+
raise
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ThreadedPageGetter:
|
|
45
|
+
"""Fetch and process individual pages supplied by a PBN paginator."""
|
|
46
|
+
|
|
47
|
+
# Subclasses bind the target model by overriding this class attribute
|
|
48
|
+
# (``model_class = MyModel``); the constructor argument takes precedence
|
|
49
|
+
# when supplied.
|
|
50
|
+
model_class = None
|
|
51
|
+
|
|
52
|
+
def __init__(self, max_workers=None, model_class=None):
|
|
53
|
+
self.max_workers = max_workers
|
|
54
|
+
if model_class is not None:
|
|
55
|
+
self.model_class = model_class
|
|
56
|
+
self.client = None
|
|
57
|
+
self.data = None
|
|
58
|
+
|
|
59
|
+
def pool_init(self, client, data):
|
|
60
|
+
django_setup()
|
|
61
|
+
self.client = client
|
|
62
|
+
self.data = data
|
|
63
|
+
|
|
64
|
+
def process_element(self, element):
|
|
65
|
+
raise NotImplementedError
|
|
66
|
+
|
|
67
|
+
def get_single_page(self, page_number):
|
|
68
|
+
for element in self.data.fetch_page(page_number):
|
|
69
|
+
self.process_element(element)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ThreadedModelSaver(ThreadedPageGetter):
|
|
73
|
+
"""Page getter that atomically stores each element in a Django model."""
|
|
74
|
+
|
|
75
|
+
save_function = staticmethod(upsert_pbn_object)
|
|
76
|
+
|
|
77
|
+
def process_element(self, element):
|
|
78
|
+
self.save_function(element, self.model_class)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Historical name: describes PBN's upstream storage, not the local database.
|
|
82
|
+
# Retained as an alias for existing consumers of the older API.
|
|
83
|
+
ThreadedMongoDBSaver = ThreadedModelSaver
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_process_getter = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _initialize_process_getter(getter, client, data):
|
|
90
|
+
"""Initialize the getter instance owned by one worker process."""
|
|
91
|
+
global _process_getter
|
|
92
|
+
|
|
93
|
+
getter.pool_init(client, data)
|
|
94
|
+
_process_getter = getter
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _get_single_page_in_process(page_number):
|
|
98
|
+
"""Dispatch a page through the getter initialized in this process."""
|
|
99
|
+
return _process_getter.get_single_page(page_number)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _make_getter(getter_class, workers):
|
|
103
|
+
try:
|
|
104
|
+
return getter_class(max_workers=workers)
|
|
105
|
+
except TypeError:
|
|
106
|
+
# Compatibility with existing getter classes that predate max_workers.
|
|
107
|
+
return getter_class()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _make_pool(method, workers, getter, client, data):
|
|
111
|
+
if method == "threads":
|
|
112
|
+
pool = ThreadPool(
|
|
113
|
+
processes=workers,
|
|
114
|
+
initializer=getter.pool_init,
|
|
115
|
+
initargs=(client, data),
|
|
116
|
+
)
|
|
117
|
+
return pool, getter.get_single_page
|
|
118
|
+
|
|
119
|
+
if method == "processes":
|
|
120
|
+
close_old_connections()
|
|
121
|
+
connections.close_all()
|
|
122
|
+
pool = multiprocessing.get_context("fork").Pool(
|
|
123
|
+
processes=workers,
|
|
124
|
+
initializer=_initialize_process_getter,
|
|
125
|
+
initargs=(getter, client, data),
|
|
126
|
+
)
|
|
127
|
+
return pool, _get_single_page_in_process
|
|
128
|
+
|
|
129
|
+
raise ValueError(f"Unsupported page download method: {method!r}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def download_pages(
|
|
133
|
+
client,
|
|
134
|
+
data,
|
|
135
|
+
*,
|
|
136
|
+
getter_class=ThreadedPageGetter,
|
|
137
|
+
workers=12,
|
|
138
|
+
label="getting...",
|
|
139
|
+
method="threads",
|
|
140
|
+
progress: PageProgressWrapper | None = None,
|
|
141
|
+
):
|
|
142
|
+
"""Fetch all paginator pages concurrently and process their elements."""
|
|
143
|
+
getter = _make_getter(getter_class, workers)
|
|
144
|
+
effective_workers = getattr(getter, "max_workers", None) or workers
|
|
145
|
+
pool, get_single_page = _make_pool(
|
|
146
|
+
method,
|
|
147
|
+
effective_workers,
|
|
148
|
+
getter,
|
|
149
|
+
client,
|
|
150
|
+
data,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
completed_pages = pool.imap_unordered(
|
|
155
|
+
get_single_page,
|
|
156
|
+
range(data.total_pages),
|
|
157
|
+
)
|
|
158
|
+
if progress is not None:
|
|
159
|
+
completed_pages = progress(
|
|
160
|
+
completed_pages,
|
|
161
|
+
data.total_pages,
|
|
162
|
+
label,
|
|
163
|
+
)
|
|
164
|
+
for _completed_page in completed_pages:
|
|
165
|
+
pass
|
|
166
|
+
except BaseException:
|
|
167
|
+
pool.terminate()
|
|
168
|
+
raise
|
|
169
|
+
else:
|
|
170
|
+
pool.close()
|
|
171
|
+
finally:
|
|
172
|
+
pool.join()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Atomic persistence and sequential download services for PBN objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Callable, Iterable
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
from django.db import IntegrityError, transaction
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
Element = TypeVar("Element")
|
|
14
|
+
ProgressWrapper = Callable[
|
|
15
|
+
[Iterable[Element], int | None, str],
|
|
16
|
+
Iterable[Element],
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _update_changed_fields(instance, values):
|
|
21
|
+
changed = False
|
|
22
|
+
for field_name, value in values.items():
|
|
23
|
+
if getattr(instance, field_name) != value:
|
|
24
|
+
setattr(instance, field_name, value)
|
|
25
|
+
changed = True
|
|
26
|
+
|
|
27
|
+
if changed:
|
|
28
|
+
# Deliberately save the complete model: BasePBNMongoDBModel.save()
|
|
29
|
+
# recalculates fields named by pull_up_on_save.
|
|
30
|
+
instance.save()
|
|
31
|
+
return instance
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@transaction.atomic
|
|
35
|
+
def upsert_pbn_object(element, model_class, client=None, **extra_fields):
|
|
36
|
+
"""Create or update one versioned PBN object atomically.
|
|
37
|
+
|
|
38
|
+
``client`` is accepted so this function can be used interchangeably with
|
|
39
|
+
application-specific download callbacks. It is not needed for a generic
|
|
40
|
+
model upsert.
|
|
41
|
+
"""
|
|
42
|
+
del client
|
|
43
|
+
|
|
44
|
+
values = {
|
|
45
|
+
"status": element["status"],
|
|
46
|
+
"verificationLevel": element["verificationLevel"],
|
|
47
|
+
"verified": element["verified"],
|
|
48
|
+
"versions": element["versions"],
|
|
49
|
+
**extra_fields,
|
|
50
|
+
}
|
|
51
|
+
object_id = element["mongoId"]
|
|
52
|
+
existing = model_class.objects.select_for_update().filter(pk=object_id)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
instance = existing.get()
|
|
56
|
+
except model_class.DoesNotExist:
|
|
57
|
+
try:
|
|
58
|
+
# The savepoint is essential. If another worker wins the insert
|
|
59
|
+
# race, its IntegrityError must not poison the enclosing atomic
|
|
60
|
+
# block before we read and update the winning row.
|
|
61
|
+
with transaction.atomic():
|
|
62
|
+
return model_class.objects.create(pk=object_id, **values)
|
|
63
|
+
except IntegrityError as create_error:
|
|
64
|
+
try:
|
|
65
|
+
instance = model_class.objects.select_for_update().get(pk=object_id)
|
|
66
|
+
except model_class.DoesNotExist:
|
|
67
|
+
raise create_error
|
|
68
|
+
|
|
69
|
+
return _update_changed_fields(instance, values)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_total_count(elements) -> int | None:
|
|
73
|
+
"""Best-effort total used by download progress integrations."""
|
|
74
|
+
total_elements = getattr(elements, "total_elements", None)
|
|
75
|
+
if total_elements is not None:
|
|
76
|
+
return total_elements
|
|
77
|
+
|
|
78
|
+
length = getattr(elements, "__len__", None)
|
|
79
|
+
if length is not None:
|
|
80
|
+
try:
|
|
81
|
+
return len(elements)
|
|
82
|
+
except TypeError:
|
|
83
|
+
logger.debug("Element collection rejected len()", exc_info=True)
|
|
84
|
+
|
|
85
|
+
count = getattr(elements, "count", None)
|
|
86
|
+
if count is None:
|
|
87
|
+
return None
|
|
88
|
+
if not callable(count):
|
|
89
|
+
return count
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
return count()
|
|
93
|
+
except Exception:
|
|
94
|
+
# Counting is optional and must not abort a data download. Keep the
|
|
95
|
+
# traceback in standard logging for hosts that need diagnostics.
|
|
96
|
+
logger.debug("Could not determine element count", exc_info=True)
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def download_pbn_objects(
|
|
101
|
+
elements,
|
|
102
|
+
model_class,
|
|
103
|
+
*,
|
|
104
|
+
label="download_pbn_objects",
|
|
105
|
+
save: Callable[..., Any] | None = None,
|
|
106
|
+
client=None,
|
|
107
|
+
progress: ProgressWrapper | None = None,
|
|
108
|
+
):
|
|
109
|
+
"""Persist every object from an iterable, optionally wrapping progress."""
|
|
110
|
+
if save is None:
|
|
111
|
+
save = upsert_pbn_object
|
|
112
|
+
|
|
113
|
+
total = get_total_count(elements)
|
|
114
|
+
iterable = progress(elements, total, label) if progress else elements
|
|
115
|
+
for element in iterable:
|
|
116
|
+
save(element, model_class, client=client)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from django.db import connection, models
|
|
3
|
+
|
|
4
|
+
from django_pbn_client.models import (
|
|
5
|
+
MAX_TEXT_FIELD_LENGTH,
|
|
6
|
+
BasePBNMongoDBModel,
|
|
7
|
+
)
|
|
8
|
+
from django_pbn_client.persistence import upsert_pbn_object
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MirrorRecord(BasePBNMongoDBModel):
|
|
12
|
+
title = models.TextField(blank=True, default="")
|
|
13
|
+
year = models.IntegerField(blank=True, null=True)
|
|
14
|
+
source = models.CharField(max_length=32, blank=True, default="")
|
|
15
|
+
pull_up_on_save = ["title", "year"]
|
|
16
|
+
|
|
17
|
+
class Meta:
|
|
18
|
+
app_label = "django_pbn_client_package_tests"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture(scope="module", autouse=True)
|
|
22
|
+
def mirror_record_table(django_db_setup, django_db_blocker):
|
|
23
|
+
with django_db_blocker.unblock(), connection.schema_editor() as editor:
|
|
24
|
+
editor.create_model(MirrorRecord)
|
|
25
|
+
|
|
26
|
+
yield
|
|
27
|
+
|
|
28
|
+
with django_db_blocker.unblock(), connection.schema_editor() as editor:
|
|
29
|
+
editor.delete_model(MirrorRecord)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _element(
|
|
33
|
+
*,
|
|
34
|
+
status="ACTIVE",
|
|
35
|
+
verification_level="VERIFIED",
|
|
36
|
+
verified=True,
|
|
37
|
+
title="A title",
|
|
38
|
+
year=2026,
|
|
39
|
+
):
|
|
40
|
+
return {
|
|
41
|
+
"mongoId": "mongo-1",
|
|
42
|
+
"status": status,
|
|
43
|
+
"verificationLevel": verification_level,
|
|
44
|
+
"verified": verified,
|
|
45
|
+
"versions": [
|
|
46
|
+
{
|
|
47
|
+
"current": True,
|
|
48
|
+
"object": {
|
|
49
|
+
"title": title,
|
|
50
|
+
"year": year,
|
|
51
|
+
"website": "https://pbn.example/object",
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.mark.django_db
|
|
59
|
+
def test_base_model_reads_current_version_and_pulls_up_text():
|
|
60
|
+
record = upsert_pbn_object(_element(title="Downloaded title"), MirrorRecord)
|
|
61
|
+
|
|
62
|
+
assert record.current_version["current"] is True
|
|
63
|
+
assert record.value("object", "title") == "Downloaded title"
|
|
64
|
+
assert record.value_or_none("object", "missing") is None
|
|
65
|
+
assert record.website() == "https://pbn.example/object"
|
|
66
|
+
assert record.title == "Downloaded title"
|
|
67
|
+
assert record.year == 2026
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.django_db
|
|
71
|
+
def test_pull_up_truncates_pathological_indexed_text():
|
|
72
|
+
title = "x" * (MAX_TEXT_FIELD_LENGTH + 50)
|
|
73
|
+
|
|
74
|
+
record = upsert_pbn_object(_element(title=title), MirrorRecord)
|
|
75
|
+
|
|
76
|
+
assert record.title == "x" * MAX_TEXT_FIELD_LENGTH
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.django_db
|
|
80
|
+
def test_upsert_updates_all_mirrored_state_and_extra_fields():
|
|
81
|
+
upsert_pbn_object(_element(title="Before"), MirrorRecord, source="api")
|
|
82
|
+
|
|
83
|
+
record = upsert_pbn_object(
|
|
84
|
+
_element(
|
|
85
|
+
status="DELETED",
|
|
86
|
+
verification_level="REJECTED",
|
|
87
|
+
verified=False,
|
|
88
|
+
title="After",
|
|
89
|
+
),
|
|
90
|
+
MirrorRecord,
|
|
91
|
+
source="offline",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
assert record.status == "DELETED"
|
|
95
|
+
assert record.verificationLevel == "REJECTED"
|
|
96
|
+
assert record.verified is False
|
|
97
|
+
assert record.source == "offline"
|
|
98
|
+
assert record.title == "After"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.mark.django_db
|
|
102
|
+
def test_unchanged_upsert_does_not_touch_last_updated_timestamp():
|
|
103
|
+
payload = _element()
|
|
104
|
+
original = upsert_pbn_object(payload, MirrorRecord)
|
|
105
|
+
original_timestamp = original.last_updated_on
|
|
106
|
+
|
|
107
|
+
unchanged = upsert_pbn_object(payload, MirrorRecord)
|
|
108
|
+
|
|
109
|
+
assert unchanged.last_updated_on == original_timestamp
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from django_pbn_client.pages import (
|
|
4
|
+
ThreadedModelSaver,
|
|
5
|
+
ThreadedMongoDBSaver,
|
|
6
|
+
ThreadedPageGetter,
|
|
7
|
+
download_pages,
|
|
8
|
+
simple_page_getter,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_threaded_model_saver_uses_injected_model_and_save_function():
|
|
13
|
+
calls = []
|
|
14
|
+
model_class = object()
|
|
15
|
+
|
|
16
|
+
class Saver(ThreadedModelSaver):
|
|
17
|
+
save_function = staticmethod(
|
|
18
|
+
lambda element, model: calls.append((element, model))
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
saver = Saver(model_class=model_class)
|
|
22
|
+
saver.process_element({"mongoId": "one"})
|
|
23
|
+
|
|
24
|
+
assert calls == [({"mongoId": "one"}, model_class)]
|
|
25
|
+
assert ThreadedMongoDBSaver is ThreadedModelSaver
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_threaded_model_saver_binds_model_via_class_attribute():
|
|
29
|
+
# download_pages() instantiates the getter class without passing
|
|
30
|
+
# model_class, so subclasses that bind the model as a class attribute
|
|
31
|
+
# must keep working — the constructor must not shadow it with None.
|
|
32
|
+
calls = []
|
|
33
|
+
sentinel_model = object()
|
|
34
|
+
|
|
35
|
+
class Saver(ThreadedModelSaver):
|
|
36
|
+
model_class = sentinel_model
|
|
37
|
+
save_function = staticmethod(
|
|
38
|
+
lambda element, model: calls.append((element, model))
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
Saver().process_element({"mongoId": "one"})
|
|
42
|
+
|
|
43
|
+
assert calls == [({"mongoId": "one"}, sentinel_model)]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_download_pages_processes_each_page_with_host_progress():
|
|
47
|
+
processed = []
|
|
48
|
+
progress_calls = []
|
|
49
|
+
|
|
50
|
+
class Data:
|
|
51
|
+
total_pages = 3
|
|
52
|
+
|
|
53
|
+
def fetch_page(self, page_number):
|
|
54
|
+
return [page_number * 2, page_number * 2 + 1]
|
|
55
|
+
|
|
56
|
+
class Getter(ThreadedPageGetter):
|
|
57
|
+
def process_element(self, element):
|
|
58
|
+
processed.append((self.client, element))
|
|
59
|
+
|
|
60
|
+
def progress(pages, total, label):
|
|
61
|
+
progress_calls.append((total, label))
|
|
62
|
+
return pages
|
|
63
|
+
|
|
64
|
+
client = object()
|
|
65
|
+
download_pages(
|
|
66
|
+
client,
|
|
67
|
+
Data(),
|
|
68
|
+
getter_class=Getter,
|
|
69
|
+
workers=2,
|
|
70
|
+
label="Pages",
|
|
71
|
+
progress=progress,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
assert progress_calls == [(3, "Pages")]
|
|
75
|
+
assert sorted(processed, key=lambda item: item[1]) == [
|
|
76
|
+
(client, 0),
|
|
77
|
+
(client, 1),
|
|
78
|
+
(client, 2),
|
|
79
|
+
(client, 3),
|
|
80
|
+
(client, 4),
|
|
81
|
+
(client, 5),
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_download_pages_rejects_unknown_concurrency_method():
|
|
86
|
+
class Data:
|
|
87
|
+
total_pages = 0
|
|
88
|
+
|
|
89
|
+
with pytest.raises(ValueError, match="greenlets"):
|
|
90
|
+
download_pages(None, Data(), method="greenlets")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.mark.django_db
|
|
94
|
+
def test_process_workers_use_their_initialized_getter():
|
|
95
|
+
class Data:
|
|
96
|
+
total_pages = 2
|
|
97
|
+
|
|
98
|
+
def fetch_page(self, _page_number):
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
download_pages(None, Data(), method="processes", workers=1)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_simple_page_getter_yields_each_page_in_order():
|
|
105
|
+
class Data:
|
|
106
|
+
total_pages = 3
|
|
107
|
+
|
|
108
|
+
def fetch_page(self, page_number):
|
|
109
|
+
return [f"page-{page_number}"]
|
|
110
|
+
|
|
111
|
+
assert list(simple_page_getter(None, Data())) == [
|
|
112
|
+
["page-0"],
|
|
113
|
+
["page-1"],
|
|
114
|
+
["page-2"],
|
|
115
|
+
]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from django.core.exceptions import ObjectDoesNotExist
|
|
3
|
+
from django.db import IntegrityError, connection
|
|
4
|
+
|
|
5
|
+
from django_pbn_client.persistence import (
|
|
6
|
+
download_pbn_objects,
|
|
7
|
+
get_total_count,
|
|
8
|
+
upsert_pbn_object,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _element():
|
|
13
|
+
return {
|
|
14
|
+
"mongoId": "race-winner",
|
|
15
|
+
"status": "ACTIVE",
|
|
16
|
+
"verificationLevel": "VERIFIED",
|
|
17
|
+
"verified": True,
|
|
18
|
+
"versions": [{"current": True, "object": {}}],
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_download_uses_paginator_total_and_host_progress_wrapper():
|
|
23
|
+
saved = []
|
|
24
|
+
progress_calls = []
|
|
25
|
+
|
|
26
|
+
class Elements:
|
|
27
|
+
total_elements = 2
|
|
28
|
+
|
|
29
|
+
def __iter__(self):
|
|
30
|
+
yield {"mongoId": "one"}
|
|
31
|
+
yield {"mongoId": "two"}
|
|
32
|
+
|
|
33
|
+
def save(element, model_class, client=None):
|
|
34
|
+
saved.append((element["mongoId"], model_class, client))
|
|
35
|
+
|
|
36
|
+
def progress(elements, total, label):
|
|
37
|
+
progress_calls.append((total, label))
|
|
38
|
+
return elements
|
|
39
|
+
|
|
40
|
+
model_class = object()
|
|
41
|
+
client = object()
|
|
42
|
+
download_pbn_objects(
|
|
43
|
+
Elements(),
|
|
44
|
+
model_class,
|
|
45
|
+
label="Downloading",
|
|
46
|
+
save=save,
|
|
47
|
+
client=client,
|
|
48
|
+
progress=progress,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
assert progress_calls == [(2, "Downloading")]
|
|
52
|
+
assert saved == [
|
|
53
|
+
("one", model_class, client),
|
|
54
|
+
("two", model_class, client),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_total_count_supports_sized_and_counted_iterables():
|
|
59
|
+
class Counted:
|
|
60
|
+
count = 7
|
|
61
|
+
|
|
62
|
+
assert get_total_count([1, 2, 3]) == 3
|
|
63
|
+
assert get_total_count(Counted()) == 7
|
|
64
|
+
assert get_total_count(iter([1, 2])) is None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.mark.django_db
|
|
68
|
+
def test_insert_race_fallback_keeps_outer_transaction_usable():
|
|
69
|
+
class RaceDoesNotExist(ObjectDoesNotExist):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
class WinningInstance:
|
|
73
|
+
status = "OLD"
|
|
74
|
+
verificationLevel = "OLD"
|
|
75
|
+
verified = False
|
|
76
|
+
versions = []
|
|
77
|
+
save_calls = 0
|
|
78
|
+
|
|
79
|
+
def save(self):
|
|
80
|
+
self.save_calls += 1
|
|
81
|
+
|
|
82
|
+
winner = WinningInstance()
|
|
83
|
+
|
|
84
|
+
class MissingQuery:
|
|
85
|
+
def get(self):
|
|
86
|
+
raise RaceDoesNotExist
|
|
87
|
+
|
|
88
|
+
class RaceManager:
|
|
89
|
+
def select_for_update(self):
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
def filter(self, **kwargs):
|
|
93
|
+
assert kwargs == {"pk": "race-winner"}
|
|
94
|
+
return MissingQuery()
|
|
95
|
+
|
|
96
|
+
def create(self, **kwargs):
|
|
97
|
+
raise IntegrityError("another worker inserted the row")
|
|
98
|
+
|
|
99
|
+
def get(self, **kwargs):
|
|
100
|
+
assert kwargs == {"pk": "race-winner"}
|
|
101
|
+
with connection.cursor() as cursor:
|
|
102
|
+
cursor.execute("SELECT 1")
|
|
103
|
+
return winner
|
|
104
|
+
|
|
105
|
+
class RaceModel:
|
|
106
|
+
objects = RaceManager()
|
|
107
|
+
DoesNotExist = RaceDoesNotExist
|
|
108
|
+
|
|
109
|
+
result = upsert_pbn_object(_element(), RaceModel)
|
|
110
|
+
|
|
111
|
+
assert result is winner
|
|
112
|
+
assert winner.status == "ACTIVE"
|
|
113
|
+
assert winner.verificationLevel == "VERIFIED"
|
|
114
|
+
assert winner.verified is True
|
|
115
|
+
assert winner.versions == [{"current": True, "object": {}}]
|
|
116
|
+
assert winner.save_calls == 1
|