pbn-client 0.1.0__py3-none-any.whl

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.
pbn_client/__init__.py ADDED
@@ -0,0 +1,53 @@
1
+ """Klient API PBN — czysta, niezależna od frameworka warstwa protokołu.
2
+
3
+ Pakiet operuje wyłącznie na pojęciach PBN: tokeny, URL-e, PBN UID-y, JSON-y
4
+ i flagi bool. Nie zna modelu domenowego żadnej aplikacji-hosta, więc może
5
+ być używany przez dowolny projekt integrujący się z PBN.
6
+ """
7
+
8
+ from .auth import OAuthMixin
9
+ from .client import PBNClient
10
+ from .mixins import (
11
+ ConferencesMixin,
12
+ DictionariesMixin,
13
+ InstitutionsMixin,
14
+ InstitutionsProfileMixin,
15
+ JournalsMixin,
16
+ PersonMixin,
17
+ PublicationsMixin,
18
+ PublishersMixin,
19
+ SearchMixin,
20
+ )
21
+ from .pagination import PageableResource
22
+ from .reporting import (
23
+ ErrorReporter,
24
+ LoggingReporter,
25
+ NullReporter,
26
+ get_default_reporter,
27
+ set_default_reporter,
28
+ )
29
+ from .transport import PBNClientTransport, RequestsTransport
30
+ from .utils import smart_content
31
+
32
+ __all__ = [
33
+ "PBNClient",
34
+ "OAuthMixin",
35
+ "ConferencesMixin",
36
+ "DictionariesMixin",
37
+ "InstitutionsMixin",
38
+ "InstitutionsProfileMixin",
39
+ "JournalsMixin",
40
+ "PersonMixin",
41
+ "PublicationsMixin",
42
+ "PublishersMixin",
43
+ "SearchMixin",
44
+ "PageableResource",
45
+ "ErrorReporter",
46
+ "LoggingReporter",
47
+ "NullReporter",
48
+ "get_default_reporter",
49
+ "set_default_reporter",
50
+ "PBNClientTransport",
51
+ "RequestsTransport",
52
+ "smart_content",
53
+ ]
pbn_client/auth.py ADDED
@@ -0,0 +1,96 @@
1
+ """OAuth authentication mixin for PBN API."""
2
+
3
+ from urllib.parse import parse_qs, urlparse
4
+
5
+ import requests
6
+
7
+ from pbn_client.conf import settings as pbn_settings
8
+ from pbn_client.exceptions import (
9
+ AuthenticationConfigurationError,
10
+ AuthenticationResponseError,
11
+ )
12
+
13
+
14
+ class OAuthMixin:
15
+ """Mixin providing OAuth authentication functionality."""
16
+
17
+ @classmethod
18
+ def get_auth_url(klass, base_url, app_id, state=None):
19
+ url = f"{base_url}/auth/pbn/api/registration/user/token/{app_id}"
20
+ if state:
21
+ from urllib.parse import quote
22
+
23
+ url += f"?state={quote(state)}"
24
+ return url
25
+
26
+ @classmethod
27
+ def get_user_token(
28
+ klass,
29
+ base_url,
30
+ app_id,
31
+ app_token,
32
+ one_time_token,
33
+ *,
34
+ timeout=None,
35
+ ):
36
+ headers = {
37
+ "X-App-Id": app_id,
38
+ "X-App-Token": app_token,
39
+ }
40
+ body = {"oneTimeToken": one_time_token}
41
+ url = f"{base_url}/auth/pbn/api/user/token"
42
+ response = requests.post(
43
+ url=url,
44
+ json=body,
45
+ headers=headers,
46
+ timeout=(
47
+ pbn_settings.PBN_CLIENT_HTTP_TIMEOUT
48
+ if timeout is None
49
+ else pbn_settings.parse_timeout(timeout)
50
+ ),
51
+ )
52
+ try:
53
+ response.json()
54
+ except ValueError as e:
55
+ if response.content.startswith(b"Mismatched X-APP-TOKEN: "):
56
+ raise AuthenticationConfigurationError(
57
+ "Token aplikacji PBN nieprawidłowy. Poproś administratora "
58
+ "o skonfigurowanie prawidłowego tokena aplikacji PBN w "
59
+ "konfiguracji aplikacji. "
60
+ ) from e
61
+
62
+ raise AuthenticationResponseError(response.content) from e
63
+
64
+ return response.json().get("X-User-Token")
65
+
66
+ def authorize(self, base_url, app_id, app_token):
67
+ if self.access_token:
68
+ return True
69
+
70
+ self.access_token = pbn_settings.PBN_CLIENT_USER_TOKEN
71
+ if self.access_token:
72
+ return True
73
+
74
+ auth_url = OAuthMixin.get_auth_url(base_url, app_id)
75
+
76
+ print(
77
+ f"""I have launched a web browser with {auth_url} ,\nplease log-in,
78
+ then paste the redirected URL below. \n"""
79
+ )
80
+ import webbrowser
81
+
82
+ webbrowser.open(auth_url)
83
+ redirect_response = input("Paste the full redirect URL here:")
84
+ one_time_token = parse_qs(urlparse(redirect_response).query).get("ott")[0]
85
+
86
+ # NIE wypisujemy one_time_token ani access_token — to aktywne sekrety,
87
+ # które zostawałyby w terminalu/CI/przechwyconych logach (uwaga #4).
88
+ self.access_token = OAuthMixin.get_user_token(
89
+ base_url,
90
+ app_id,
91
+ app_token,
92
+ one_time_token,
93
+ timeout=getattr(self, "timeout", None),
94
+ )
95
+
96
+ return True
pbn_client/client.py ADDED
@@ -0,0 +1,113 @@
1
+ """Czysty klient protokołu PBN.
2
+
3
+ ``PBNClient`` to kompozycja mixinów protokołu (słownikowo-CRUD + silnik
4
+ oświadczeń ``StatementsMixin``). Operuje na tokenach, PBN UID-ach, JSON-ach
5
+ i flagach bool — nie zna modelu domenowego aplikacji-hosta.
6
+
7
+ Orkiestracja synchronizacji specyficzna dla aplikacji (znająca jej rekordy
8
+ i konfigurację instytucji) powinna żyć w warstwie aplikacji, jako klasa
9
+ dziedzicząca po ``PBNClient``.
10
+ """
11
+
12
+ import sys
13
+ from collections.abc import Iterable
14
+ from pprint import pprint
15
+
16
+ from .mixins import (
17
+ ConferencesMixin,
18
+ DictionariesMixin,
19
+ InstitutionsMixin,
20
+ InstitutionsProfileMixin,
21
+ JournalsMixin,
22
+ PersonMixin,
23
+ PublicationsMixin,
24
+ PublishersMixin,
25
+ SearchMixin,
26
+ )
27
+ from .statements import StatementsMixin
28
+ from .transport import RequestsTransport
29
+
30
+
31
+ class PBNClient(
32
+ ConferencesMixin,
33
+ DictionariesMixin,
34
+ InstitutionsMixin,
35
+ InstitutionsProfileMixin,
36
+ JournalsMixin,
37
+ PersonMixin,
38
+ PublicationsMixin,
39
+ PublishersMixin,
40
+ SearchMixin,
41
+ StatementsMixin,
42
+ ):
43
+ """Czysty klient protokołu PBN (bez orkiestracji specyficznej dla aplikacji)."""
44
+
45
+ _interactive = False
46
+
47
+ def __init__(self, transport: RequestsTransport):
48
+ self.transport = transport
49
+
50
+ def _get_command_function(self, cmd):
51
+ """Get function to execute from command name."""
52
+ try:
53
+ return getattr(self, cmd[0])
54
+ except AttributeError as e:
55
+ if self._interactive:
56
+ print(f"No such command: {cmd}")
57
+ return None
58
+ raise e
59
+
60
+ def _extract_arguments(self, lst):
61
+ """Extract positional and keyword arguments from command list."""
62
+ args = ()
63
+ kw = {}
64
+ for elem in lst:
65
+ if elem.find(":") >= 1:
66
+ k, n = elem.split(":", 1)
67
+ kw[k] = n
68
+ else:
69
+ args += (elem,)
70
+ return args, kw
71
+
72
+ def _print_non_interactive_result(self, res):
73
+ """Print result in non-interactive mode."""
74
+ import json
75
+
76
+ print(json.dumps(res))
77
+
78
+ def _print_interactive_result(self, res):
79
+ """Print result in interactive mode."""
80
+ if type(res) is dict:
81
+ pprint(res)
82
+ elif isinstance(res, Iterable):
83
+ if self._interactive and hasattr(res, "total_elements"):
84
+ print(
85
+ "Incoming data: no_elements=",
86
+ res.total_elements,
87
+ "no_pages=",
88
+ res.total_pages,
89
+ )
90
+ input("Press ENTER to continue> ")
91
+ for elem in res:
92
+ pprint(elem)
93
+
94
+ def exec(self, cmd):
95
+ fun = self._get_command_function(cmd)
96
+ if fun is None:
97
+ return
98
+
99
+ args, kw = self._extract_arguments(cmd[1:])
100
+ res = fun(*args, **kw)
101
+
102
+ if not sys.stdout.isatty():
103
+ self._print_non_interactive_result(res)
104
+ else:
105
+ self._print_interactive_result(res)
106
+
107
+ def interactive(self):
108
+ self._interactive = True
109
+ while True:
110
+ cmd = input("cmd> ")
111
+ if cmd == "exit":
112
+ break
113
+ self.exec(cmd.split(" "))
File without changes
@@ -0,0 +1,46 @@
1
+ """Environment defaults for the standalone PBN client.
2
+
3
+ Applications should normally pass credentials and timeouts to the transport
4
+ constructor. These variables remain available for command-line compatibility.
5
+ No framework settings object is imported here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from collections.abc import Sequence
12
+
13
+ from pbn_client.const import DEFAULT_BASE_URL
14
+
15
+ Timeout = float | tuple[float, float]
16
+ DEFAULT_HTTP_TIMEOUT: Timeout = (30.0, 120.0)
17
+
18
+
19
+ def parse_timeout(
20
+ raw: str | float | Sequence[float] | None,
21
+ default: Timeout = DEFAULT_HTTP_TIMEOUT,
22
+ ) -> Timeout:
23
+ """Parse a requests timeout from a scalar or ``connect,read`` value."""
24
+
25
+ if raw is None:
26
+ return default
27
+ if isinstance(raw, (int, float)):
28
+ return float(raw)
29
+ if isinstance(raw, Sequence) and not isinstance(raw, str):
30
+ values = tuple(float(value) for value in raw)
31
+ return values if len(values) == 2 else default
32
+
33
+ parts = [part.strip() for part in str(raw).split(",") if part.strip()]
34
+ if len(parts) == 1:
35
+ return float(parts[0])
36
+ if len(parts) == 2:
37
+ return (float(parts[0]), float(parts[1]))
38
+ return default
39
+
40
+
41
+ PBN_CLIENT_DEFAULT_BASE_URL = DEFAULT_BASE_URL
42
+ PBN_CLIENT_APP_ID = os.getenv("PBN_CLIENT_APP_ID")
43
+ PBN_CLIENT_APP_TOKEN = os.getenv("PBN_CLIENT_APP_TOKEN")
44
+ PBN_CLIENT_BASE_URL = os.getenv("PBN_CLIENT_BASE_URL", DEFAULT_BASE_URL)
45
+ PBN_CLIENT_USER_TOKEN = os.getenv("PBN_CLIENT_USER_TOKEN")
46
+ PBN_CLIENT_HTTP_TIMEOUT = parse_timeout(os.getenv("PBN_CLIENT_HTTP_TIMEOUT"))
pbn_client/const.py ADDED
@@ -0,0 +1,33 @@
1
+ DELETED = "DELETED"
2
+ ACTIVE = "ACTIVE"
3
+
4
+ PBN_POST_PUBLICATIONS_URL = "/api/v1/publications"
5
+
6
+ PBN_POST_PUBLICATION_NO_STATEMENTS_URL = "/api/v1/repositorium/publications"
7
+
8
+ PBN_POST_PUBLICATION_FEE_URL = "/api/v1/institutionProfile/publications/fees/{id}"
9
+
10
+ PBN_GET_LANGUAGES_URL = "/api/v1/dictionary/languages"
11
+ PBN_SEARCH_PUBLICATIONS_URL = "/api/v1/search/publications"
12
+
13
+ PBN_GET_JOURNAL_BY_ID = "/api/v1/journals/{id}"
14
+
15
+ DEFAULT_BASE_URL = "https://pbn-micro-alpha.opi.org.pl"
16
+
17
+ NEEDS_PBN_AUTH_MSG = (
18
+ "W celu poprawnej autentykacji należy podać poprawny token użytkownika aplikacji."
19
+ )
20
+ PBN_DELETE_PUBLICATION_STATEMENT = (
21
+ "/api/v1/institutionProfile/publications/{publicationId}"
22
+ )
23
+ PBN_GET_PUBLICATION_BY_ID_URL = "/api/v1/publications/id/{id}"
24
+
25
+ PBN_GET_INSTITUTION_STATEMENTS = (
26
+ "/api/v1/institutionProfile/publications/page/statements"
27
+ )
28
+
29
+ PBN_GET_DISCIPLINES_URL = "/api/v2/dictionary/disciplines"
30
+
31
+ PBN_POST_INSTITUTION_STATEMENTS_URL = "/api/v2/institution-profile/statements"
32
+
33
+ PBN_GET_INSTITUTION_PUBLICATIONS_V2 = "/api/v2/institution-profile/publications"
@@ -0,0 +1,48 @@
1
+ def rename_dict_key(data, old_key, new_key):
2
+ """
3
+ Recursively rename a dictionary key in a dictionary and all nested dictionaries.
4
+
5
+ Args:
6
+ data: Dictionary or any data structure that may contain dictionaries
7
+ old_key: The key to be renamed
8
+ new_key: The new key name
9
+
10
+ Returns:
11
+ The modified data structure with renamed keys
12
+ """
13
+ if isinstance(data, dict):
14
+ # Create a new dictionary with renamed keys
15
+ new_dict = {}
16
+ for key, value in data.items():
17
+ # Rename the key if it matches
18
+ new_key_name = new_key if key == old_key else key
19
+ # Recursively process the value
20
+ new_dict[new_key_name] = rename_dict_key(value, old_key, new_key)
21
+ return new_dict
22
+ elif isinstance(data, list):
23
+ # Process each item in the list
24
+ return [rename_dict_key(item, old_key, new_key) for item in data]
25
+ else:
26
+ # Return the value unchanged if it's not a dict or list
27
+ return data
28
+
29
+
30
+ def compare_dicts(d1, d2, path=""):
31
+ diffs = []
32
+ keys = set(d1.keys()) | set(d2.keys())
33
+
34
+ for key in keys:
35
+ key_path = f"{path}.{key}" if path else key
36
+
37
+ if key not in d1:
38
+ diffs.append(f"Key '{key_path}' missing in first dict")
39
+ elif key not in d2:
40
+ diffs.append(f"Key '{key_path}' missing in second dict")
41
+ else:
42
+ v1, v2 = d1[key], d2[key]
43
+ if isinstance(v1, dict) and isinstance(v2, dict):
44
+ diffs.extend(compare_dicts(v1, v2, key_path))
45
+ elif v1 != v2:
46
+ diffs.append(f"Value mismatch at '{key_path}': {v1!r} != {v2!r}")
47
+
48
+ return diffs
@@ -0,0 +1,141 @@
1
+ """Exceptions raised by the framework-independent PBN protocol client."""
2
+
3
+ import json
4
+
5
+ __all__ = [
6
+ "AccessDeniedException",
7
+ "AuthenticationConfigurationError",
8
+ "AuthenticationResponseError",
9
+ "CannotDeleteStatementsException",
10
+ "CannotUploadPublicationFee",
11
+ "HttpException",
12
+ "NeedsPBNAuthorisationException",
13
+ "PBNValidationError",
14
+ "PraceSerwisoweException",
15
+ "PublicationDoesNotExistInInstitutionProfile",
16
+ "ResourceLockedException",
17
+ "SciencistDoesNotExist",
18
+ "StatementsResendFailedException",
19
+ "parse_pbn_validation_details",
20
+ ]
21
+
22
+
23
+ class PraceSerwisoweException(Exception):
24
+ def __str__(self):
25
+ return "Po stronie PBN trwają prace serwisowe. Prosimy spróbować później. "
26
+
27
+
28
+ class CannotDeleteStatementsException(Exception):
29
+ pass
30
+
31
+
32
+ class HttpException(Exception):
33
+ def __init__(self, status_code, url, content):
34
+ self.status_code = status_code
35
+ self.url = url
36
+ self.content = content
37
+ try:
38
+ self.json = json.loads(content[:4096])
39
+ except (json.JSONDecodeError, ValueError, TypeError):
40
+ self.json = None
41
+
42
+
43
+ class ResourceLockedException(HttpException):
44
+ pass
45
+
46
+
47
+ class AccessDeniedException(Exception):
48
+ def __init__(self, url, content):
49
+ self.url = url
50
+ self.content = content
51
+
52
+
53
+ class SciencistDoesNotExist(Exception):
54
+ """The requested scientist does not exist in PBN."""
55
+
56
+
57
+ class AuthenticationConfigurationError(Exception):
58
+ pass
59
+
60
+
61
+ class AuthenticationResponseError(Exception):
62
+ pass
63
+
64
+
65
+ class NeedsPBNAuthorisationException(HttpException):
66
+ pass
67
+
68
+
69
+ class CannotUploadPublicationFee(ValueError):
70
+ """PBN rejected a fee because the publication has no fee obligation."""
71
+
72
+
73
+ class PublicationDoesNotExistInInstitutionProfile(ValueError):
74
+ """The publication is missing from the PBN institution profile."""
75
+
76
+
77
+ class StatementsResendFailedException(Exception):
78
+ """Statement synchronization failed after exhausting its retry policy."""
79
+
80
+ def __init__(self, publication_pk, pbn_uid, last_error):
81
+ self.publication_pk = publication_pk
82
+ self.pbn_uid = pbn_uid
83
+ self.last_error = last_error
84
+ super().__init__(
85
+ f"Synchronizacja oświadczeń dla pracy pk={publication_pk} "
86
+ f"(PBN UID={pbn_uid}) nie powiodła się po wyczerpaniu prób: "
87
+ f"{last_error}"
88
+ )
89
+
90
+
91
+ def parse_pbn_validation_details(parsed_json):
92
+ """Return deduplicated PBN validation messages or ``None``.
93
+
94
+ Recognizes both the object-shaped ``details`` response and the list-shaped
95
+ response containing ``message``, ``description`` or ``code`` fields.
96
+ Hostile values are converted to strings and malformed elements are skipped.
97
+ """
98
+
99
+ def _coerce(value):
100
+ if isinstance(value, (list, tuple)):
101
+ return ", ".join(str(item) for item in value)
102
+ return str(value)
103
+
104
+ messages = []
105
+ if isinstance(parsed_json, dict):
106
+ details = parsed_json.get("details")
107
+ if isinstance(details, dict) and details:
108
+ messages = [_coerce(value) for value in details.values()]
109
+ elif isinstance(parsed_json, list) and parsed_json:
110
+ for element in parsed_json:
111
+ if not isinstance(element, dict):
112
+ continue
113
+ text = (
114
+ element.get("message")
115
+ or element.get("description")
116
+ or element.get("code")
117
+ )
118
+ if text:
119
+ messages.append(_coerce(text))
120
+
121
+ if not messages:
122
+ return None
123
+ return list(dict.fromkeys(messages))
124
+
125
+
126
+ class PBNValidationError(HttpException):
127
+ """PBN rejected user data with a validation response.
128
+
129
+ ``__str__`` intentionally remains the inherited ``HttpException`` form:
130
+ consumers that parse the original exception tuple rely on the raw
131
+ response body remaining available.
132
+ """
133
+
134
+ def __init__(self, status_code, url, content):
135
+ super().__init__(status_code, url, content)
136
+ self.messages = parse_pbn_validation_details(self.json) or []
137
+
138
+ def user_messages(self):
139
+ """Return deduplicated messages suitable for display to a user."""
140
+
141
+ return self.messages
@@ -0,0 +1,22 @@
1
+ """API endpoint mixins for PBN client."""
2
+
3
+ from .conferences import ConferencesMixin
4
+ from .dictionaries import DictionariesMixin
5
+ from .institutions import InstitutionsMixin, InstitutionsProfileMixin
6
+ from .journals import JournalsMixin
7
+ from .person import PersonMixin
8
+ from .publications import PublicationsMixin
9
+ from .publishers import PublishersMixin
10
+ from .search import SearchMixin
11
+
12
+ __all__ = [
13
+ "ConferencesMixin",
14
+ "DictionariesMixin",
15
+ "InstitutionsMixin",
16
+ "InstitutionsProfileMixin",
17
+ "JournalsMixin",
18
+ "PersonMixin",
19
+ "PublicationsMixin",
20
+ "PublishersMixin",
21
+ "SearchMixin",
22
+ ]
@@ -0,0 +1,20 @@
1
+ """Conferences API mixin."""
2
+
3
+
4
+ class ConferencesMixin:
5
+ """Mixin providing conference-related API methods."""
6
+
7
+ def get_conferences(self, *args, **kw):
8
+ return self.transport.get_pages("/api/v1/conferences/page", *args, **kw)
9
+
10
+ def get_conferences_mnisw(self, *args, **kw):
11
+ return self.transport.get_pages("/api/v1/conferences/mnisw/page", *args, **kw)
12
+
13
+ def get_conference(self, id):
14
+ return self.transport.get(f"/api/v1/conferences/{id}")
15
+
16
+ def get_conference_editions(self, id):
17
+ return self.transport.get(f"/api/v1/conferences/{id}/editions")
18
+
19
+ def get_conference_metadata(self, id):
20
+ return self.transport.get(f"/api/v1/conferences/{id}/metadata")
@@ -0,0 +1,16 @@
1
+ """Dictionaries API mixin."""
2
+
3
+ from pbn_client.const import PBN_GET_DISCIPLINES_URL, PBN_GET_LANGUAGES_URL
4
+
5
+
6
+ class DictionariesMixin:
7
+ """Mixin providing dictionary-related API methods."""
8
+
9
+ def get_countries(self):
10
+ return self.transport.get("/api/v1/dictionary/countries")
11
+
12
+ def get_disciplines(self):
13
+ return self.transport.get(PBN_GET_DISCIPLINES_URL)
14
+
15
+ def get_languages(self):
16
+ return self.transport.get(PBN_GET_LANGUAGES_URL)