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.
@@ -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,19 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .venv/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ uv.lock
10
+
11
+ # Editors / IDEs
12
+ .idea/
13
+ .vscode/
14
+ *.sublime-workspace
15
+
16
+ # OS turds
17
+ .DS_Store
18
+ Thumbs.db
19
+ desktop.ini
@@ -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
+ [![CI](https://github.com/iplweb/django-pbn-client/actions/workflows/ci.yml/badge.svg)](https://github.com/iplweb/django-pbn-client/actions/workflows/ci.yml)
33
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
34
+ [![Python](https://img.shields.io/badge/python-3.10%20%E2%80%93%203.14-blue.svg)](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
+ [![CI](https://github.com/iplweb/django-pbn-client/actions/workflows/ci.yml/badge.svg)](https://github.com/iplweb/django-pbn-client/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![Python](https://img.shields.io/badge/python-3.10%20%E2%80%93%203.14-blue.svg)](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,10 @@
1
+ SECRET_KEY = "django-pbn-client-tests"
2
+ INSTALLED_APPS = []
3
+ DATABASES = {
4
+ "default": {
5
+ "ENGINE": "django.db.backends.sqlite3",
6
+ "NAME": ":memory:",
7
+ }
8
+ }
9
+ DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
10
+ USE_TZ = True
@@ -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