mysql-shell-client 0.6.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.
@@ -0,0 +1,188 @@
1
+ # Copyright 2025 Canonical Ltd.
2
+ # See LICENSE file for licensing details.
3
+
4
+ import json
5
+ import re
6
+ import subprocess
7
+ from typing import Generator
8
+
9
+ from ..models import ConnectionDetails
10
+ from .base import BaseExecutor
11
+ from .errors import ExecutionError
12
+
13
+
14
+ class LocalExecutor(BaseExecutor):
15
+ """Local executor for the MySQL Shell."""
16
+
17
+ def __init__(self, conn_details: ConnectionDetails, shell_path: str):
18
+ """Initialize the executor."""
19
+ super().__init__(conn_details, shell_path)
20
+
21
+ def _common_args(self) -> list[str]:
22
+ """Return the list of common arguments."""
23
+ return [
24
+ self._shell_path,
25
+ "--json=raw",
26
+ "--save-passwords=never",
27
+ "--passwords-from-stdin",
28
+ ]
29
+
30
+ def _connection_args(self) -> list[str]:
31
+ """Return the list of connection arguments."""
32
+ if self._conn_details.socket:
33
+ return [
34
+ f"--socket={self._conn_details.socket}",
35
+ f"--user={self._conn_details.username}",
36
+ ]
37
+ else:
38
+ return [
39
+ f"--host={self._conn_details.host}",
40
+ f"--port={self._conn_details.port}",
41
+ f"--user={self._conn_details.username}",
42
+ ]
43
+
44
+ def _parse_error(self, output: str) -> dict:
45
+ """Parse the execution error."""
46
+ error = next(self._iter_output(output, "error"), None)
47
+ if not error:
48
+ error = {}
49
+
50
+ return error
51
+
52
+ def _parse_output_py(self, output: str) -> str:
53
+ """Parse the Python execution output."""
54
+ result = next(self._iter_output(output, "info"), None)
55
+ if not result:
56
+ result = "{}"
57
+
58
+ return result
59
+
60
+ def _parse_output_sql(self, output: str) -> list:
61
+ """Parse the SQL execution output."""
62
+ result = next(self._iter_output(output, "rows"), None)
63
+ if not result:
64
+ result = []
65
+
66
+ return result
67
+
68
+ @staticmethod
69
+ def _iter_output(output: str, key: str) -> Generator:
70
+ """Iterates over the log lines in reversed order."""
71
+ logs = output.split("\n")
72
+
73
+ # MySQL Shell always prints prompts and warnings first
74
+ for log in reversed(logs):
75
+ if not log:
76
+ continue
77
+
78
+ log = json.loads(log)
79
+ val = log.get(key)
80
+ if not isinstance(val, str) or val.strip():
81
+ yield val
82
+
83
+ @staticmethod
84
+ def _strip_password(error: subprocess.SubprocessError):
85
+ """Strip passwords from SQL scripts."""
86
+ if not hasattr(error, "cmd"):
87
+ return error
88
+
89
+ password_pattern = re.compile("(?<=IDENTIFIED BY ')[^']+(?=')")
90
+ password_replace = "*****"
91
+
92
+ for index, value in enumerate(error.cmd):
93
+ if "IDENTIFIED BY" in value:
94
+ error.cmd[index] = re.sub(password_pattern, password_replace, value)
95
+
96
+ return error
97
+
98
+ def check_connection(self) -> None:
99
+ """Check the connection."""
100
+ command = [
101
+ *self._common_args(),
102
+ *self._connection_args(),
103
+ ]
104
+
105
+ try:
106
+ subprocess.check_output(
107
+ command,
108
+ input=self._conn_details.password,
109
+ text=True,
110
+ )
111
+ except subprocess.CalledProcessError as exc:
112
+ err = self._parse_error(exc.output)
113
+ raise ExecutionError(err)
114
+ except subprocess.TimeoutExpired:
115
+ raise ExecutionError()
116
+
117
+ def execute_py(self, script: str, *, timeout: int | None = None) -> str:
118
+ """Execute a Python script.
119
+
120
+ Arguments:
121
+ script: Python script to execute
122
+ timeout: Optional timeout seconds
123
+
124
+ Returns:
125
+ String with the output of the MySQL Shell command.
126
+ The output cannot be parsed to JSON, as the output depends on the script
127
+ """
128
+ # Prepend every Python command with useWizards=False, to disable interactive mode.
129
+ # Cannot be set on command line as it conflicts with --passwords-from-stdin.
130
+ script = "shell.options.set('useWizards', False)\n" + script
131
+
132
+ command = [
133
+ *self._common_args(),
134
+ *self._connection_args(),
135
+ "--py",
136
+ "--execute",
137
+ script,
138
+ ]
139
+
140
+ try:
141
+ output = subprocess.check_output(
142
+ command,
143
+ timeout=timeout,
144
+ input=self._conn_details.password,
145
+ text=True,
146
+ )
147
+ except subprocess.CalledProcessError as exc:
148
+ err = self._parse_error(exc.output)
149
+ raise ExecutionError(err)
150
+ except subprocess.TimeoutExpired:
151
+ raise ExecutionError()
152
+ else:
153
+ return self._parse_output_py(output)
154
+
155
+ def execute_sql(self, script: str, *, timeout: int | None = None) -> list[dict]:
156
+ """Execute a SQL script.
157
+
158
+ Arguments:
159
+ script: SQL script to execute
160
+ timeout: Optional timeout seconds
161
+
162
+ Returns:
163
+ List of dictionaries, one per returned row
164
+ """
165
+ command = [
166
+ *self._common_args(),
167
+ *self._connection_args(),
168
+ "--sql",
169
+ "--execute",
170
+ script,
171
+ ]
172
+
173
+ try:
174
+ output = subprocess.check_output(
175
+ command,
176
+ timeout=timeout,
177
+ input=self._conn_details.password,
178
+ text=True,
179
+ )
180
+ except subprocess.CalledProcessError as exc:
181
+ err = self._parse_error(exc.output)
182
+ exc = self._strip_password(exc)
183
+ raise ExecutionError(err) from exc
184
+ except subprocess.TimeoutExpired as exc:
185
+ exc = self._strip_password(exc)
186
+ raise ExecutionError() from exc
187
+ else:
188
+ return self._parse_output_sql(output)
@@ -0,0 +1,8 @@
1
+ # Copyright 2025 Canonical Ltd.
2
+ # See LICENSE file for licensing details.
3
+
4
+ from .account import *
5
+ from .cluster import *
6
+ from .connection import *
7
+ from .instance import *
8
+ from .statement import *
@@ -0,0 +1,49 @@
1
+ # Copyright 2025 Canonical Ltd.
2
+ # See LICENSE file for licensing details.
3
+
4
+ import json
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class Role:
10
+ """MySQL role account."""
11
+
12
+ rolename: str
13
+ hostname: str = "%"
14
+
15
+ @classmethod
16
+ def from_row(cls, rolename: str, hostname: str):
17
+ """Create a role account from a MySQL row."""
18
+ return Role(
19
+ rolename=rolename,
20
+ hostname=hostname,
21
+ )
22
+
23
+
24
+ @dataclass
25
+ class User:
26
+ """MySQL user account."""
27
+
28
+ username: str
29
+ hostname: str = "%"
30
+ attributes: dict | None = None
31
+
32
+ @classmethod
33
+ def from_row(cls, username: str, hostname: str, attributes: str | None):
34
+ """Create a user account from a MySQL row."""
35
+ if not attributes:
36
+ attributes = "{}"
37
+
38
+ return User(
39
+ username=username,
40
+ hostname=hostname,
41
+ attributes=json.loads(attributes),
42
+ )
43
+
44
+ def serialize_attrs(self) -> str:
45
+ """Serialize the user attributes."""
46
+ if not self.attributes:
47
+ return "{}"
48
+
49
+ return json.dumps(self.attributes)
@@ -0,0 +1,57 @@
1
+ # Copyright 2025 Canonical Ltd.
2
+ # See LICENSE file for licensing details.
3
+
4
+ from enum import Enum
5
+
6
+
7
+ class ClusterSetStatus(str, Enum):
8
+ """MySQL cluster-set statuses.
9
+
10
+ https://dev.mysql.com/doc/mysql-shell/8.0/en/innodb-clusterset-status.html
11
+ """
12
+
13
+ HEALTHY = "HEALTHY"
14
+ AVAILABLE = "AVAILABLE"
15
+ UNAVAILABLE = "UNAVAILABLE"
16
+
17
+
18
+ class ClusterGlobalStatus(str, Enum):
19
+ """MySQL cluster statuses within a cluster-set context.
20
+
21
+ https://dev.mysql.com/doc/mysql-shell/8.0/en/innodb-clusterset-status.html
22
+ """
23
+
24
+ OK = "OK"
25
+ OK_NOT_REPLICATING = "OK_NOT_REPLICATING"
26
+ OK_NOT_CONSISTENT = "OK_NOT_CONSISTENT"
27
+ OK_MISCONFIGURED = "OK_MISCONFIGURED"
28
+ NOT_OK = "NOT_OK"
29
+ UNKNOWN = "UNKNOWN"
30
+ INVALIDATED = "INVALIDATED"
31
+
32
+
33
+ class ClusterRole(str, Enum):
34
+ """MySQL cluster roles."""
35
+
36
+ PRIMARY = "PRIMARY"
37
+ REPLICA = "REPLICA"
38
+
39
+
40
+ class ClusterStatus(str, Enum):
41
+ """MySQL cluster statuses.
42
+
43
+ There is a slight discrepancy between the possible cluster statuses reported
44
+ on different MySQL documentation pages, this list contains the common ones across them.
45
+ - https://dev.mysql.com/doc/mysql-shell/8.0/en/innodb-clusterset-status.html
46
+ - https://dev.mysql.com/doc/mysql-shell/8.0/en/monitoring-innodb-cluster.html
47
+ """
48
+
49
+ OK = "OK"
50
+ OK_PARTIAL = "OK_PARTIAL"
51
+ OK_NO_TOLERANCE = "OK_NO_TOLERANCE"
52
+ OK_NO_TOLERANCE_PARTIAL = "OK_NO_TOLERANCE_PARTIAL"
53
+ NO_QUORUM = "NO_QUORUM"
54
+ OFFLINE = "OFFLINE"
55
+ ERROR = "ERROR"
56
+ UNREACHABLE = "UNREACHABLE"
57
+ UNKNOWN = "UNKNOWN"
@@ -0,0 +1,22 @@
1
+ # Copyright 2025 Canonical Ltd.
2
+ # See LICENSE file for licensing details.
3
+
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass
8
+ class ConnectionDetails:
9
+ """MySQL connection details."""
10
+
11
+ username: str
12
+ password: str
13
+ host: str = ""
14
+ port: str = ""
15
+ socket: str = ""
16
+
17
+ def __post_init__(self) -> None:
18
+ """Validates that the connection details are correct."""
19
+ if (not self.host or not self.port) and not self.socket:
20
+ raise ValueError("Connection details must not be empty")
21
+ if (self.host or self.port) and self.socket:
22
+ raise ValueError("Connection details must not be state both TCP and socket values")
@@ -0,0 +1,27 @@
1
+ # Copyright 2025 Canonical Ltd.
2
+ # See LICENSE file for licensing details.
3
+
4
+ from enum import Enum
5
+
6
+
7
+ class InstanceRole(str, Enum):
8
+ """MySQL instance roles."""
9
+
10
+ PRIMARY = "PRIMARY"
11
+ SECONDARY = "SECONDARY"
12
+
13
+
14
+ class InstanceState(str, Enum):
15
+ """MySQL instance states.
16
+
17
+ There is a slight discrepancy between the possible instance states reported
18
+ by different MySQL mechanisms, this list contains the common ones across them.
19
+ - https://dev.mysql.com/doc/refman/8.0/en/group-replication-server-states.html
20
+ - https://dev.mysql.com/doc/mysql-shell/8.0/en/monitoring-innodb-cluster.html
21
+ """
22
+
23
+ ONLINE = "ONLINE"
24
+ RECOVERING = "RECOVERING"
25
+ OFFLINE = "OFFLINE"
26
+ ERROR = "ERROR"
27
+ UNREACHABLE = "UNREACHABLE"
@@ -0,0 +1,30 @@
1
+ # Copyright 2025 Canonical Ltd.
2
+ # See LICENSE file for licensing details.
3
+
4
+ from enum import Enum
5
+
6
+
7
+ class LogType(str, Enum):
8
+ """MySQL log types.
9
+
10
+ https://dev.mysql.com/doc/refman/8.0/en/flush.html#flush-logs
11
+ """
12
+
13
+ BINARY = "BINARY"
14
+ ENGINE = "ENGINE"
15
+ ERROR = "ERROR"
16
+ GENERAL = "GENERAL"
17
+ RELAY = "RELAY"
18
+ SLOW = "SLOW"
19
+
20
+
21
+ class VariableScope(str, Enum):
22
+ """MySQL variable scopes.
23
+
24
+ https://dev.mysql.com/doc/refman/8.0/en/set-variable.html
25
+ """
26
+
27
+ GLOBAL = "GLOBAL"
28
+ SESSION = "SESSION"
29
+ PERSIST = "PERSIST"
30
+ PERSIST_ONLY = "PERSIST_ONLY"
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: mysql-shell-client
3
+ Version: 0.6.0
4
+ Summary: Python client for MySQL Shell
5
+ License-Expression: Apache-2.0
6
+ License-File: LICENSE
7
+ Author: Sinclert Perez
8
+ Author-email: sinclert.perez@canonical.com
9
+ Maintainer: Canonical Data Platform
10
+ Maintainer-email: data-platform@lists.launchpad.net
11
+ Requires-Python: >=3.10
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Provides-Extra: format
19
+ Provides-Extra: lint
20
+ Provides-Extra: test
21
+ Requires-Dist: codespell (>=2.3,<3.0) ; extra == "lint"
22
+ Requires-Dist: coverage (>=7.6,<8.0) ; extra == "test"
23
+ Requires-Dist: pytest (>=8.3,<9.0) ; extra == "test"
24
+ Requires-Dist: ruff (>=0.9,<1.0) ; extra == "format"
25
+ Requires-Dist: ruff (>=0.9,<1.0) ; extra == "lint"
26
+ Project-URL: Changelog, https://github.com/canonical/mysql-shell-client/blob/main/CHANGELOG.md
27
+ Project-URL: Homepage, https://github.com/canonical/mysql-shell-client
28
+ Project-URL: Issues, https://github.com/canonical/mysql-shell-client/issues
29
+ Description-Content-Type: text/markdown
30
+
31
+ # MySQL Shell Python client
32
+
33
+ [![CI/CD Status][ci-status-badge]][ci-status-link]
34
+ [![Coverage Status][cov-status-badge]][cov-status-link]
35
+ [![Apache license][apache-license-badge]][apache-license-link]
36
+
37
+ MySQL Shell is an advanced client for MySQL Server that allow system administrator to perform both
38
+ cluster and instance level operations, using a single binary.
39
+
40
+ This project provides a Python client to perform the most common set of operations,
41
+ in addition to a set of predefined queries to cover most of the common use-cases.
42
+
43
+ ## 🧑‍💻 Usage
44
+
45
+ 1. Install the package from PyPi:
46
+ ```shell
47
+ pip install mysql-shell-client
48
+ ```
49
+
50
+ 2. Import and build the executors:
51
+ ```python
52
+ from mysql_shell.executors import LocalExecutor
53
+ from mysql_shell.models import ConnectionDetails
54
+
55
+ cluster_conn = ConnectionDetails(
56
+ username="...",
57
+ password="...",
58
+ host="...",
59
+ port="...",
60
+ )
61
+ instance_conn = ConnectionDetails(
62
+ username="...",
63
+ password="...",
64
+ host="...",
65
+ port="...",
66
+ )
67
+
68
+ cluster_executor = LocalExecutor(cluster_conn, "mysqlsh")
69
+ instance_executor = LocalExecutor(instance_conn, "mysqlsh")
70
+ ```
71
+
72
+ 3. Import and build the query builders **[optional]**:
73
+ ```python
74
+ from mysql_shell.builders import CharmLockingQueryBuilder
75
+
76
+ # This is just an example
77
+ builder = CharmLockingQueryBuilder("mysql", "locking")
78
+ query = builder.build_table_creation_query()
79
+ rows = instance_executor.execute_sql(query)
80
+ ```
81
+
82
+ 4. Import and build the clients:
83
+ ```python
84
+ from mysql_shell.clients import MySQLClusterClient, MySQLInstanceClient
85
+
86
+ cluster_client = MySQLClusterClient(cluster_executor)
87
+ instance_client = MySQLInstanceClient(instance_executor)
88
+ ```
89
+
90
+
91
+ ## 🔧 Development
92
+
93
+ ### Dependencies
94
+ In order to install all the development packages:
95
+
96
+ ```shell
97
+ poetry install --all-extras
98
+ ```
99
+
100
+ ### Linting
101
+ All Python files are linted using [Ruff][docs-ruff], to run it:
102
+
103
+ ```shell
104
+ tox -e lint
105
+ ```
106
+
107
+ ### Testing
108
+ Project testing is performed using [Pytest][docs-pytest], to run them:
109
+
110
+ ```shell
111
+ tox -e unit
112
+ ```
113
+
114
+ ```shell
115
+ export MYSQL_DATABASE="test"
116
+ export MYSQL_USERNAME="root"
117
+ export MYSQL_PASSWORD="root_pass"
118
+ export MYSQL_SHELL_PATH="mysqlsh"
119
+
120
+ podman-compose -f compose/mysql-8.0.yaml up --detach && tox -e integration
121
+ podman-compose -f compose/mysql-8.0.yaml down
122
+ ```
123
+
124
+ ### Release
125
+ Commits can be tagged to create releases of the package, in order to do so:
126
+
127
+ 1. Bump up the version within the `pyproject.toml` file.
128
+ 2. Add a new section to the `CHANGELOG.md`.
129
+ 3. Commit + push the changes.
130
+ 4. Trigger the [release workflow][github-workflows].
131
+
132
+
133
+ [apache-license-badge]: https://img.shields.io/badge/License-Apache%202.0-blue.svg
134
+ [apache-license-link]: https://github.com/canonical/mysql-shell-client/blob/main/LICENSE
135
+ [ci-status-badge]: https://github.com/canonical/mysql-shell-client/actions/workflows/ci.yaml/badge.svg?branch=main
136
+ [ci-status-link]: https://github.com/canonical/mysql-shell-client/actions/workflows/ci.yaml?query=branch%3Amain
137
+ [cov-status-badge]: https://codecov.io/gh/canonical/mysql-shell-client/branch/main/graph/badge.svg
138
+ [cov-status-link]: https://codecov.io/gh/canonical/mysql-shell-client
139
+
140
+ [docs-pytest]: https://docs.pytest.org/en/latest/#
141
+ [docs-ruff]: https://docs.astral.sh/ruff/
142
+ [github-workflows]: https://github.com/canonical/mysql-shell-client/actions/workflows/release.yaml
143
+
@@ -0,0 +1,30 @@
1
+ mysql_shell/__init__.py,sha256=MfYnXwgdAN0FdILtYZBo-gR6xpsDs9nBBVUI120R4co,169
2
+ mysql_shell/builders/__init__.py,sha256=hw7c-Z3DGnL_DnjU3zzj1wnQ73KmRWCYTQgu090GllU,173
3
+ mysql_shell/builders/authorization/__init__.py,sha256=mKu4sNa1L65d1gpzokxdHmGVGJOlUA8FufTOAus90-w,173
4
+ mysql_shell/builders/authorization/base.py,sha256=ueo_Wo6JsL9UW1JEUit5tJabLxKZok_Jgxdm3sElNEs,768
5
+ mysql_shell/builders/authorization/charm.py,sha256=PRglXU2UKhzo3tWdPLVJbTCBeJaEc8aVNRNndrrgFdU,7336
6
+ mysql_shell/builders/locking/__init__.py,sha256=Qy1Q_YDEDGAs7q9pmCWtZpcIi9P_C_3OIqUyNaroITM,161
7
+ mysql_shell/builders/locking/base.py,sha256=bZj943m_uvmHTtlBS4-q1mDN9u9b0vNXXSV6eYhMsKw,880
8
+ mysql_shell/builders/locking/charm.py,sha256=JaAae3CTxzgnZMbBx2l5BmA_4mxy8HHBggjnlIxTQ2U,3267
9
+ mysql_shell/builders/logging/__init__.py,sha256=zM_LjTJlOTNlgfli1CJFvKiuo0OqSgtUcyEUITgyXJg,161
10
+ mysql_shell/builders/logging/base.py,sha256=mFjOHl3iMah11Pddk95ap7QrozpKLRgEbV3qcUJy0ns,413
11
+ mysql_shell/builders/logging/charm.py,sha256=A6PelFVHxiTDOUnQ-vjlPwCGIlvokKN-3YcD_iAhOWY,1132
12
+ mysql_shell/builders/quoting.py,sha256=mhVo2md38m3ok9kn12lSDjl2-V_SKp-LWyvr3uSOSPo,1934
13
+ mysql_shell/clients/__init__.py,sha256=D4y63dGJUsq7Pklqo_vaijhVqdp640X-6H9eZDS8KCc,122
14
+ mysql_shell/clients/cluster.py,sha256=IIkumRhAyMhxjH7vT5cNHLLQi8djxVz8sxyzrPSQT5w,15024
15
+ mysql_shell/clients/instance.py,sha256=Vy_yhwxJ6T4ChiC4IiP9TVer8mCzC5A7O6ENyzHJuv8,18419
16
+ mysql_shell/executors/__init__.py,sha256=mplYyzgA-22b7GNjVUEmEiI4lN0tgIl9qlgNHe3tRPI,139
17
+ mysql_shell/executors/base.py,sha256=rsvOLSWvlxtYcVDq_FeNoEBphixv-iW0Fa1OdWqgG6M,1089
18
+ mysql_shell/executors/errors/__init__.py,sha256=zAJdgtRRGGcdMJg-5nNy3iS7FcGKyCstbQko0QT4n5k,111
19
+ mysql_shell/executors/errors/runtime.py,sha256=er7m8HECfeAkMgT7UI0TvWK9hh56MxL0meO291GqM0c,382
20
+ mysql_shell/executors/local.py,sha256=9wrL7o_vopZYtTEgwSCaeL949qJSFpo-u6oOUvOuDqU,5858
21
+ mysql_shell/models/__init__.py,sha256=erB_kCQjGfKik32UTcm_GUndZVCefPepKe60SkE99cU,196
22
+ mysql_shell/models/account.py,sha256=u8DGcq2SgxYxZKqmykQaSmcxTiO1yQt9yWMg1f_TtDw,1101
23
+ mysql_shell/models/cluster.py,sha256=rA1V7lohODVaJvvSEQKr325tz_eiafN5LsPO-ZH6ChQ,1546
24
+ mysql_shell/models/connection.py,sha256=YTxJP_49TWxQR6QZjP_u8OxHfPOubCEIHkckU38Fbt4,665
25
+ mysql_shell/models/instance.py,sha256=br2mvraBrMqigsUGb794n5XUybDD5e9eOVJEujyHd2k,745
26
+ mysql_shell/models/statement.py,sha256=rm9nf-qKlRc2snVyw2KgISdFuhdH7Kvext-7KQbhRZI,591
27
+ mysql_shell_client-0.6.0.dist-info/METADATA,sha256=8lUDCYLBPpRIyL1hufg2-vQsfNabBpcciemzo87UvHY,4645
28
+ mysql_shell_client-0.6.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
29
+ mysql_shell_client-0.6.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
30
+ mysql_shell_client-0.6.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.2.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any