rsconnect-python 1.30.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.
- rsconnect/__init__.py +13 -0
- rsconnect/actions.py +565 -0
- rsconnect/actions_content.py +508 -0
- rsconnect/actions_environment.py +160 -0
- rsconnect/actions_integration.py +118 -0
- rsconnect/api.py +2582 -0
- rsconnect/bundle.py +2481 -0
- rsconnect/certificates.py +39 -0
- rsconnect/environment.py +390 -0
- rsconnect/environment_node.py +115 -0
- rsconnect/environment_r.py +300 -0
- rsconnect/exception.py +15 -0
- rsconnect/git_metadata.py +180 -0
- rsconnect/http_support.py +595 -0
- rsconnect/json_web_token.py +178 -0
- rsconnect/log.py +253 -0
- rsconnect/main.py +5889 -0
- rsconnect/metadata.py +879 -0
- rsconnect/models.py +835 -0
- rsconnect/oauth.py +623 -0
- rsconnect/py.typed +0 -0
- rsconnect/pyproject.py +283 -0
- rsconnect/quickstart/__init__.py +16 -0
- rsconnect/quickstart/quickstart.py +486 -0
- rsconnect/quickstart/templates/__init__.py +16 -0
- rsconnect/quickstart/templates/api/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/api/__connect__.py.tmpl +3 -0
- rsconnect/quickstart/templates/api/__init__.py.tmpl +1 -0
- rsconnect/quickstart/templates/api/__main__.py.tmpl +14 -0
- rsconnect/quickstart/templates/api/app.py.tmpl +11 -0
- rsconnect/quickstart/templates/api/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/fastapi/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/fastapi/__connect__.py.tmpl +3 -0
- rsconnect/quickstart/templates/fastapi/__init__.py.tmpl +1 -0
- rsconnect/quickstart/templates/fastapi/__main__.py.tmpl +16 -0
- rsconnect/quickstart/templates/fastapi/app.py.tmpl +11 -0
- rsconnect/quickstart/templates/fastapi/pyproject.toml.tmpl +14 -0
- rsconnect/quickstart/templates/notebook/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/notebook/notebook.ipynb.tmpl +34 -0
- rsconnect/quickstart/templates/notebook/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/quarto/README.md.tmpl +19 -0
- rsconnect/quickstart/templates/quarto/pyproject.toml.tmpl +11 -0
- rsconnect/quickstart/templates/quarto/report.qmd.tmpl +8 -0
- rsconnect/quickstart/templates/shiny/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/shiny/app.py.tmpl +3 -0
- rsconnect/quickstart/templates/shiny/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/streamlit/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/streamlit/app.py.tmpl +3 -0
- rsconnect/quickstart/templates/streamlit/pyproject.toml.tmpl +13 -0
- rsconnect/quickstart/templates/voila/README.md.tmpl +15 -0
- rsconnect/quickstart/templates/voila/pyproject.toml.tmpl +14 -0
- rsconnect/shiny_express.py +136 -0
- rsconnect/snowflake.py +93 -0
- rsconnect/subprocesses/__init__.py +0 -0
- rsconnect/subprocesses/inspect_environment.py +362 -0
- rsconnect/timeouts.py +89 -0
- rsconnect/utils_package.py +261 -0
- rsconnect/validation.py +156 -0
- rsconnect/version_check.py +154 -0
- rsconnect_python-1.30.0.dist-info/METADATA +89 -0
- rsconnect_python-1.30.0.dist-info/RECORD +63 -0
- rsconnect_python-1.30.0.dist-info/WHEEL +4 -0
- rsconnect_python-1.30.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Json Web Token (JWT) utilities
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import binascii
|
|
9
|
+
import os
|
|
10
|
+
from datetime import datetime, timedelta, timezone
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
import jwt
|
|
14
|
+
|
|
15
|
+
from .exception import RSConnectException
|
|
16
|
+
from .http_support import HTTPResponse, JsonData
|
|
17
|
+
from .models import BootstrapOutputDTO
|
|
18
|
+
|
|
19
|
+
DEFAULT_ISSUER = "rsconnect-python"
|
|
20
|
+
DEFAULT_AUDIENCE = "rsconnect"
|
|
21
|
+
|
|
22
|
+
BOOTSTRAP_SCOPE = "bootstrap"
|
|
23
|
+
BOOTSTRAP_EXP = timedelta(minutes=15)
|
|
24
|
+
|
|
25
|
+
SECRET_KEY_ENV = "CONNECT_BOOTSTRAP_SECRETKEY"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def read_secret_key(keypath: Optional[str]) -> bytes:
|
|
29
|
+
"""
|
|
30
|
+
Reads a secret key as bytes given a path to a file containing a base64-encoded key.
|
|
31
|
+
|
|
32
|
+
The secret key can optionally be set with an environment variable.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
env_raw_data = os.getenv(SECRET_KEY_ENV)
|
|
36
|
+
|
|
37
|
+
if keypath is not None and env_raw_data is not None:
|
|
38
|
+
raise RSConnectException("Cannot specify secret key using both a keyfile and environment variable.")
|
|
39
|
+
|
|
40
|
+
if keypath is None and env_raw_data is None:
|
|
41
|
+
raise RSConnectException("Must specify secret key using either a keyfile or environment variable.")
|
|
42
|
+
|
|
43
|
+
# check if secret key was specified using an env variable first
|
|
44
|
+
if env_raw_data is not None:
|
|
45
|
+
try:
|
|
46
|
+
return base64.b64decode(env_raw_data.encode("utf-8"))
|
|
47
|
+
except binascii.Error:
|
|
48
|
+
raise RSConnectException("Unable to decode base64 data from environment variable: " + SECRET_KEY_ENV)
|
|
49
|
+
|
|
50
|
+
if keypath is None:
|
|
51
|
+
raise RSConnectException("Keypath must not be None.")
|
|
52
|
+
|
|
53
|
+
if not os.path.exists(keypath):
|
|
54
|
+
raise RSConnectException("Keypath does not exist.")
|
|
55
|
+
|
|
56
|
+
with open(keypath, "r") as f:
|
|
57
|
+
raw_data = f.read()
|
|
58
|
+
if raw_data is None:
|
|
59
|
+
raise RSConnectException("Secret key cannot be 'None'")
|
|
60
|
+
try:
|
|
61
|
+
return base64.b64decode(raw_data)
|
|
62
|
+
except binascii.Error:
|
|
63
|
+
raise RSConnectException("Unable to decode base64 data from keyfile: " + keypath)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# https://www.ibm.com/docs/vi/sva/9.0.6?topic=jwt-support
|
|
67
|
+
def validate_hs256_secret_key(key: bytes):
|
|
68
|
+
if len(key) < 32:
|
|
69
|
+
raise RSConnectException("Secret key expected to be at least 32 bytes in length")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_client_response(response: BootstrapOutputDTO | HTTPResponse) -> tuple[int, BootstrapOutputDTO | JsonData]:
|
|
73
|
+
"""
|
|
74
|
+
Helper to handle the response type from RSConnectClient, because
|
|
75
|
+
it can have different types depending on the response
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
if isinstance(response, dict):
|
|
79
|
+
return 200, response
|
|
80
|
+
elif isinstance(response, HTTPResponse):
|
|
81
|
+
# fail fast if a non-http exception occurred
|
|
82
|
+
if hasattr(response, "exception") and response.exception is not None:
|
|
83
|
+
raise RSConnectException(str(response.exception))
|
|
84
|
+
|
|
85
|
+
status = 500
|
|
86
|
+
if hasattr(response, "status"):
|
|
87
|
+
status = response.status
|
|
88
|
+
|
|
89
|
+
json_data: JsonData = {}
|
|
90
|
+
if hasattr(response, "json_data"):
|
|
91
|
+
json_data = response.json_data
|
|
92
|
+
|
|
93
|
+
return status, json_data
|
|
94
|
+
|
|
95
|
+
raise RSConnectException("Unrecognized response type: " + str(type(response)))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def produce_bootstrap_output(status: int, json_data: BootstrapOutputDTO | JsonData) -> dict[str, int | str]:
|
|
99
|
+
"""
|
|
100
|
+
Produces the expected programmatic output format from a request to the initial_admin endpoint
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
# Parse the returned API key if one is provided
|
|
104
|
+
api_key = ""
|
|
105
|
+
if isinstance(json_data, dict) and "api_key" in json_data:
|
|
106
|
+
api_key = json_data["api_key"]
|
|
107
|
+
if not isinstance(api_key, str):
|
|
108
|
+
raise RSConnectException("Connect returned a non-string value for api_key.")
|
|
109
|
+
|
|
110
|
+
# Catch unexpected response states and error early
|
|
111
|
+
if status == 200 and api_key == "":
|
|
112
|
+
raise RSConnectException("Connect returned a successful HTTP response but no API key.")
|
|
113
|
+
|
|
114
|
+
if status != 200 and api_key != "":
|
|
115
|
+
raise RSConnectException("Connect returned a non-successful HTTP response and an API key. ")
|
|
116
|
+
|
|
117
|
+
output = {"status": status, "api_key": api_key}
|
|
118
|
+
|
|
119
|
+
# Create a helpful error message
|
|
120
|
+
message = "Unexpected response status."
|
|
121
|
+
if status == 200:
|
|
122
|
+
message = "Success."
|
|
123
|
+
elif status == 401:
|
|
124
|
+
message = "JWT authorization failed."
|
|
125
|
+
elif status == 403:
|
|
126
|
+
message = "Unable to provision initial admin. Please check status of Connect database."
|
|
127
|
+
elif status == 404:
|
|
128
|
+
message = (
|
|
129
|
+
"Unable to find provisioning endpoint. Please check your 'rsconnect bootstrap --server' "
|
|
130
|
+
"parameter and your Connect configuration."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
output["message"] = message
|
|
134
|
+
|
|
135
|
+
return output
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class JWTEncoder:
|
|
139
|
+
def __init__(self, issuer: str, audience: str, secret: str | bytes):
|
|
140
|
+
self.issuer = issuer
|
|
141
|
+
self.audience = audience
|
|
142
|
+
self.secret = secret
|
|
143
|
+
|
|
144
|
+
def generate_standard_claims(self, current_datetime: datetime, exp: timedelta):
|
|
145
|
+
|
|
146
|
+
if exp < timedelta(0):
|
|
147
|
+
raise RSConnectException("Unable to generate a token with a negative exp claim.")
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
"exp": int((current_datetime + exp).timestamp()),
|
|
151
|
+
"iss": self.issuer,
|
|
152
|
+
"aud": self.audience,
|
|
153
|
+
"iat": int(current_datetime.timestamp()),
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
def new_token(self, custom_claims: dict[str, Any], exp: timedelta) -> str:
|
|
157
|
+
|
|
158
|
+
standard_claims = self.generate_standard_claims(datetime.now(tz=timezone.utc), exp)
|
|
159
|
+
|
|
160
|
+
claims: dict[str, Any] = {}
|
|
161
|
+
for c in [standard_claims, custom_claims]:
|
|
162
|
+
claims.update(c)
|
|
163
|
+
|
|
164
|
+
return jwt.encode(claims, self.secret, algorithm="HS256") # pyright: ignore[reportUnknownMemberType]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# Uses a generic encoder to create JWTs with specific custom scopes / expiration times
|
|
168
|
+
class TokenGenerator:
|
|
169
|
+
"""
|
|
170
|
+
Generates 'typed' JWTs with specific custom scopes / expiration times to serve a specific purpose.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def __init__(self, secret: str | bytes):
|
|
174
|
+
self.encoder = JWTEncoder(DEFAULT_ISSUER, DEFAULT_AUDIENCE, secret)
|
|
175
|
+
|
|
176
|
+
def bootstrap(self):
|
|
177
|
+
custom_claims = {"scope": BOOTSTRAP_SCOPE}
|
|
178
|
+
return self.encoder.new_token(custom_claims, BOOTSTRAP_EXP)
|
rsconnect/log.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Logging wrapper and shared instance
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
from functools import partial, wraps
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Protocol, TypeVar
|
|
12
|
+
|
|
13
|
+
if sys.version_info >= (3, 10):
|
|
14
|
+
from typing import Concatenate, ParamSpec
|
|
15
|
+
else:
|
|
16
|
+
from typing_extensions import Concatenate, ParamSpec
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import MutableMapping
|
|
21
|
+
|
|
22
|
+
import click
|
|
23
|
+
|
|
24
|
+
T = TypeVar("T")
|
|
25
|
+
P = ParamSpec("P")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
|
|
29
|
+
|
|
30
|
+
VERBOSE = int((logging.INFO + logging.DEBUG) / 2)
|
|
31
|
+
logging.addLevelName(VERBOSE, "VERBOSE")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LogOutputFormat(object):
|
|
35
|
+
TEXT = "text"
|
|
36
|
+
JSON = "json"
|
|
37
|
+
DEFAULT = TEXT
|
|
38
|
+
_all = [TEXT, JSON]
|
|
39
|
+
All = Literal["text", "json"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class JsonLogFormatter(logging.Formatter):
|
|
43
|
+
"""
|
|
44
|
+
https://stackoverflow.com/a/70223539
|
|
45
|
+
Formatter that outputs JSON strings after parsing the LogRecord.
|
|
46
|
+
|
|
47
|
+
@param dict fmt_dict: Key: logging format attribute pairs.
|
|
48
|
+
@param str datefmt: Key: strftime format string
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, fmt_dict: Optional[dict[str, str]] = None, datefmt: str = _DATE_FORMAT):
|
|
52
|
+
self.fmt_dict = (
|
|
53
|
+
fmt_dict if fmt_dict is not None else {"timestamp": "asctime", "level": "levelname", "message": "message"}
|
|
54
|
+
)
|
|
55
|
+
self.datefmt = datefmt
|
|
56
|
+
|
|
57
|
+
def usesTime(self):
|
|
58
|
+
"""
|
|
59
|
+
Overwritten to look for the attribute in the format dict values instead of the fmt string.
|
|
60
|
+
"""
|
|
61
|
+
return "asctime" in self.fmt_dict.values()
|
|
62
|
+
|
|
63
|
+
def formatMessage(self, record: logging.LogRecord): # pyright: ignore[reportIncompatibleMethodOverride]
|
|
64
|
+
"""
|
|
65
|
+
Overwritten to return a dictionary of the relevant LogRecord attributes instead of a string.
|
|
66
|
+
KeyError is raised if an unknown attribute is provided in the fmt_dict.
|
|
67
|
+
"""
|
|
68
|
+
return {fmt_key: record.__dict__[fmt_val] for fmt_key, fmt_val in self.fmt_dict.items()}
|
|
69
|
+
|
|
70
|
+
def format(self, record: logging.LogRecord):
|
|
71
|
+
"""
|
|
72
|
+
Mostly the same as the parent's class method, the difference being that a dict is manipulated and dumped as JSON
|
|
73
|
+
instead of a string.
|
|
74
|
+
"""
|
|
75
|
+
record.message = record.getMessage()
|
|
76
|
+
|
|
77
|
+
if self.usesTime():
|
|
78
|
+
record.asctime = self.formatTime(record, self.datefmt)
|
|
79
|
+
|
|
80
|
+
message_dict = self.formatMessage(record)
|
|
81
|
+
|
|
82
|
+
if record.exc_info:
|
|
83
|
+
# Cache the traceback text to avoid converting it multiple times
|
|
84
|
+
# (it's constant anyway)
|
|
85
|
+
if not record.exc_text:
|
|
86
|
+
record.exc_text = self.formatException(record.exc_info)
|
|
87
|
+
|
|
88
|
+
if record.exc_text:
|
|
89
|
+
message_dict["exc_info"] = record.exc_text
|
|
90
|
+
|
|
91
|
+
if record.stack_info:
|
|
92
|
+
message_dict["stack_info"] = self.formatStack(record.stack_info)
|
|
93
|
+
|
|
94
|
+
return json.dumps(message_dict, default=str)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# This is a workaround for LoggerAdapter not being generic in Python<=3.10.
|
|
98
|
+
# See also:
|
|
99
|
+
# https://github.com/python/typeshed/issues/7855#issuecomment-1128857842
|
|
100
|
+
if sys.version_info >= (3, 11):
|
|
101
|
+
_LoggerAdapter = logging.LoggerAdapter[logging.Logger]
|
|
102
|
+
else:
|
|
103
|
+
_LoggerAdapter = logging.LoggerAdapter
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class RSLogger(_LoggerAdapter):
|
|
107
|
+
def __init__(self):
|
|
108
|
+
super(RSLogger, self).__init__(logging.getLogger("rsconnect"), {})
|
|
109
|
+
self._in_feedback = False
|
|
110
|
+
self._have_feedback_output = False
|
|
111
|
+
self._log_format = LogOutputFormat.DEFAULT
|
|
112
|
+
|
|
113
|
+
def addHandler(self, handler: logging.Handler):
|
|
114
|
+
self.logger.addHandler(handler)
|
|
115
|
+
|
|
116
|
+
def set_in_feedback(self, value: bool):
|
|
117
|
+
self._in_feedback = value
|
|
118
|
+
self._have_feedback_output = False
|
|
119
|
+
|
|
120
|
+
def set_log_output_format(self, value: LogOutputFormat.All):
|
|
121
|
+
self._log_format = value
|
|
122
|
+
if self._log_format == LogOutputFormat.JSON:
|
|
123
|
+
for h in self.logger.handlers:
|
|
124
|
+
h.setFormatter(JsonLogFormatter())
|
|
125
|
+
else:
|
|
126
|
+
for h in self.logger.handlers:
|
|
127
|
+
h.setFormatter(logging.Formatter("[%(levelname)s] %(asctime)s %(message)s", datefmt=_DATE_FORMAT))
|
|
128
|
+
|
|
129
|
+
def process(self, msg: str, kwargs: MutableMapping[str, Any]):
|
|
130
|
+
msg, kwargs = super(RSLogger, self).process(msg, kwargs)
|
|
131
|
+
if self._in_feedback and self.is_debugging():
|
|
132
|
+
if not self._have_feedback_output:
|
|
133
|
+
print()
|
|
134
|
+
self._have_feedback_output = True
|
|
135
|
+
msg = click.style(" %s" % msg, fg="green")
|
|
136
|
+
return msg, kwargs
|
|
137
|
+
|
|
138
|
+
def is_debugging(self):
|
|
139
|
+
return self.isEnabledFor(logging.DEBUG)
|
|
140
|
+
|
|
141
|
+
def setLevel(self, level: int | str):
|
|
142
|
+
"""
|
|
143
|
+
Set the specified level on the underlying logger.
|
|
144
|
+
|
|
145
|
+
**Note:** This is present in newer Python versions but since it's missing
|
|
146
|
+
from 2.7, we replicate it here.
|
|
147
|
+
"""
|
|
148
|
+
self.logger.setLevel(level)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
logger = RSLogger()
|
|
152
|
+
logger.addHandler(logging.StreamHandler())
|
|
153
|
+
logger.set_log_output_format(LogOutputFormat.DEFAULT)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class ConsoleFormatter(logging.Formatter):
|
|
157
|
+
green = "\x1b[32;20m"
|
|
158
|
+
yellow = "\x1b[33;20m"
|
|
159
|
+
red = "\x1b[31;20m"
|
|
160
|
+
msg_format = "%(message)s"
|
|
161
|
+
reset = "\x1b[0m"
|
|
162
|
+
|
|
163
|
+
FORMATS = {
|
|
164
|
+
logging.DEBUG: green + msg_format + reset,
|
|
165
|
+
logging.INFO: reset + msg_format + reset,
|
|
166
|
+
logging.WARNING: yellow + msg_format + reset,
|
|
167
|
+
logging.ERROR: red + msg_format + reset,
|
|
168
|
+
logging.CRITICAL: red + msg_format + reset,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
def format(self, record: logging.LogRecord):
|
|
172
|
+
log_fmt = self.FORMATS.get(record.levelno)
|
|
173
|
+
formatter = logging.Formatter(log_fmt)
|
|
174
|
+
return formatter.format(record)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
console_logger = logging.getLogger("console")
|
|
178
|
+
console_logger.setLevel(logging.DEBUG)
|
|
179
|
+
|
|
180
|
+
# create console handler
|
|
181
|
+
console_handler = logging.StreamHandler()
|
|
182
|
+
console_handler.terminator = ""
|
|
183
|
+
console_handler.setLevel(logging.DEBUG)
|
|
184
|
+
console_handler.setFormatter(ConsoleFormatter())
|
|
185
|
+
console_logger.addHandler(console_handler)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def logged(logger: logging.Logger, label: str):
|
|
189
|
+
def decorator(f: Callable[P, T]) -> Callable[P, T]:
|
|
190
|
+
@wraps(f)
|
|
191
|
+
def wrapper(*args: P.args, **kw: P.kwargs):
|
|
192
|
+
logger.info(label)
|
|
193
|
+
result = None
|
|
194
|
+
try:
|
|
195
|
+
result = f(*args, **kw)
|
|
196
|
+
except Exception as exc:
|
|
197
|
+
logger.error(" \t[ERROR]: {}\n".format(str(exc)))
|
|
198
|
+
raise
|
|
199
|
+
logger.debug(" \t[OK]\n")
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
return wrapper
|
|
203
|
+
|
|
204
|
+
return decorator
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class HasLoggerMethod(Protocol):
|
|
208
|
+
logger: logging.Logger | None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
HasLoggerMethodT = TypeVar("HasLoggerMethodT", bound=HasLoggerMethod)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def cls_logged(label: str): # uses logger provided by a class' self.logger
|
|
215
|
+
def decorator(
|
|
216
|
+
method: Callable[Concatenate[HasLoggerMethodT, P], T],
|
|
217
|
+
) -> Callable[Concatenate[HasLoggerMethodT, P], T]:
|
|
218
|
+
|
|
219
|
+
@wraps(method)
|
|
220
|
+
def wrapper(self: HasLoggerMethodT, *args: P.args, **kw: P.kwargs):
|
|
221
|
+
logger = self.logger
|
|
222
|
+
if logger:
|
|
223
|
+
logger.info(label)
|
|
224
|
+
result = None
|
|
225
|
+
try:
|
|
226
|
+
result = method(self, *args, **kw)
|
|
227
|
+
except Exception as exc:
|
|
228
|
+
msg = " \t[ERROR]: {}\n"
|
|
229
|
+
if logger:
|
|
230
|
+
logger.error(msg.format(str(exc)))
|
|
231
|
+
else:
|
|
232
|
+
print(msg)
|
|
233
|
+
raise
|
|
234
|
+
if logger:
|
|
235
|
+
logger.debug(" \t[OK]\n")
|
|
236
|
+
return result
|
|
237
|
+
|
|
238
|
+
return wrapper
|
|
239
|
+
|
|
240
|
+
return decorator
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
console_logged = partial(logged, console_logger)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# generic logger
|
|
247
|
+
connect_logger = logging.getLogger("connect_logger")
|
|
248
|
+
connect_logger.setLevel(logging.DEBUG)
|
|
249
|
+
connect_handler = logging.StreamHandler()
|
|
250
|
+
connect_handler.terminator = "\n"
|
|
251
|
+
connect_handler.setLevel(logging.DEBUG)
|
|
252
|
+
connect_handler.setFormatter(ConsoleFormatter())
|
|
253
|
+
connect_logger.addHandler(connect_handler)
|