ezr-database-sdk 0.1.2__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,16 @@
1
+ """EZR internal database metadata SDK (synchronous and read-only)."""
2
+ from .client import ConnectionInfo, EzrDatabaseSDK
3
+ from .errors import (
4
+ SDKError, ConfigurationError, MetadataNotFoundError,
5
+ AmbiguousMetadataError, DatabaseError,
6
+ )
7
+ from .routing import (
8
+ get_code, physical_table_name, physical_table_name_by_int, physical_table_name_by_hash,
9
+ )
10
+
11
+ __all__ = [
12
+ 'EzrDatabaseSDK', 'ConnectionInfo', 'SDKError', 'ConfigurationError',
13
+ 'MetadataNotFoundError', 'AmbiguousMetadataError', 'DatabaseError',
14
+ 'get_code', 'physical_table_name_by_int', 'physical_table_name_by_hash',
15
+ 'physical_table_name', # Deprecated compatibility entry point.
16
+ ]
@@ -0,0 +1,180 @@
1
+ """Synchronous, metadata-only client. An instance must not be shared across threads."""
2
+ from collections.abc import Mapping
3
+ from pathlib import Path
4
+ import warnings
5
+
6
+ import pymysql
7
+
8
+ from .config import load_environment, read_yaml
9
+ from .errors import DatabaseError, MetadataNotFoundError, AmbiguousMetadataError
10
+ from .routing import (
11
+ physical_table_name_by_int, physical_table_name_by_hash,
12
+ _validate_int_key, _validate_hash_key,
13
+ )
14
+
15
+ # Explicit mapping: business SystemCode values do not always match brand field names.
16
+ _BRAND_FIELDS = {
17
+ 'bd': 'BdDbShardingId', 'crm': 'CrmDbShardingId',
18
+ 'eop': 'EopDbShardingId', 'mall': 'MallDbShardingId',
19
+ 'order': 'OrderDbShardingId', 'cms': 'CmsDbShardingId',
20
+ 'rtl': 'RtlDbShardingId', 'log': 'LogDbShardingId',
21
+ 'prod': 'ProdDbShardingId', 'pay': 'PayDbShardingId',
22
+ 'ubs': 'UbsDbShardingId', 'dlake': 'DLakeShardingId',
23
+ 'bigdata': 'BigDataShardingId', 'es': 'ESDbShardingId',
24
+ 'esproduct': 'ESProdutShardingId', 'esreport': 'ESReportShardingId',
25
+ 'oms': 'OmsDbShardingId',
26
+ }
27
+
28
+
29
+ class ConnectionInfo(dict):
30
+ """Original DB fields; repr hides the credential-bearing connection string.
31
+
32
+ Explicit key access and serialization still return the real credentials.
33
+ """
34
+
35
+ def __repr__(self):
36
+ return repr({key: '<redacted>' if key == 'DbConnString' else value
37
+ for key, value in self.items()})
38
+
39
+ __str__ = __repr__
40
+
41
+
42
+ class EzrDatabaseSDK:
43
+ """Read brand metadata for one configured environment, with lazy connection."""
44
+
45
+ def __init__(self, config: Mapping, *, env: str):
46
+ self._config = load_environment(config, env)
47
+ self.env = env
48
+ self._connection = None
49
+ self._closed = False
50
+
51
+ @classmethod
52
+ def from_yaml(cls, path: str | Path, *, env: str):
53
+ return cls(read_yaml(path), env=env)
54
+
55
+ def _ensure_open(self):
56
+ if self._closed:
57
+ raise RuntimeError('SDK instance has been closed')
58
+
59
+ def _query(self, sql: str, params: tuple) -> list[dict]:
60
+ self._ensure_open()
61
+ try:
62
+ if self._connection is None:
63
+ cfg = self._config
64
+ self._connection = pymysql.connect(
65
+ host=cfg.host, port=cfg.port, user=cfg.user, password=cfg.password,
66
+ charset='utf8mb4', autocommit=True,
67
+ connect_timeout=cfg.connect_timeout, read_timeout=cfg.read_timeout,
68
+ write_timeout=cfg.write_timeout,
69
+ cursorclass=pymysql.cursors.DictCursor,
70
+ )
71
+ with self._connection.cursor() as cursor:
72
+ cursor.execute(sql, params)
73
+ return list(cursor.fetchall())
74
+ except Exception:
75
+ # Driver errors may include credentials or connection-string data.
76
+ raise DatabaseError('Database metadata read failed; check connectivity, permissions and schema') from None
77
+
78
+ def _shard_id(self, brand_id: int, database: str) -> int:
79
+ self._ensure_open()
80
+ if type(brand_id) is not int:
81
+ raise TypeError('brand_id must be an integer')
82
+ if brand_id <= 0:
83
+ raise ValueError('brand_id must be positive')
84
+ if not isinstance(database, str) or database.lower() not in _BRAND_FIELDS:
85
+ raise ValueError('Unsupported database; see the documented business database codes')
86
+ field = _BRAND_FIELDS[database.lower()]
87
+ rows = self._query(
88
+ f'SELECT * FROM `{self._config.brand_database}`.`ed_base_brand` WHERE `Id` = %s',
89
+ (brand_id,),
90
+ )
91
+ if not rows:
92
+ raise MetadataNotFoundError(f'Brand {brand_id} was not found')
93
+ if len(rows) != 1:
94
+ raise AmbiguousMetadataError('Multiple brand metadata records found')
95
+ value = rows[0].get(field)
96
+ if type(value) is not int or value <= 0:
97
+ raise MetadataNotFoundError(f'Brand {brand_id} has no valid {field} assignment')
98
+ return value
99
+
100
+ def get_table_shards(self, brand_id: int, database: str, table_code: str | None = None) -> list[dict]:
101
+ """Return raw table rules for a brand's business shard, including CopId.
102
+
103
+ CopId is not interpreted or silently prioritized. All matching rows are returned.
104
+ """
105
+ if table_code is not None and (not isinstance(table_code, str) or not table_code.strip()):
106
+ raise ValueError('table_code must be a nonempty string when provided')
107
+ shard_id = self._shard_id(brand_id, database)
108
+ sql = (f'SELECT * FROM `{self._config.base_database}`.`pf_sys_db_tableshard` '
109
+ 'WHERE `ShardDbId` = %s')
110
+ params = (shard_id,)
111
+ if table_code is not None:
112
+ sql += ' AND `TableCode` = %s'
113
+ params += (table_code,)
114
+ return self._query(sql + ' ORDER BY `Id`', params)
115
+
116
+ def get_connection_strings(self, brand_id: int, database: str) -> list[ConnectionInfo]:
117
+ """Return all primary/replica and DbIndex nodes, preserving original fields."""
118
+ shard_id = self._shard_id(brand_id, database)
119
+ rows = self._query(
120
+ f'SELECT * FROM `{self._config.base_database}`.`pf_sys_db_shard_nodes` '
121
+ 'WHERE `DbShardingId` = %s ORDER BY `DbIndex`, `IsMaster` DESC, `Id`',
122
+ (shard_id,),
123
+ )
124
+ return [ConnectionInfo(row) for row in rows]
125
+
126
+ def _get_table_rule(self, brand_id: int, database: str, table_code: str) -> dict:
127
+ rows = self.get_table_shards(brand_id, database, table_code)
128
+ if not rows:
129
+ raise MetadataNotFoundError('No table shard rule found; unsharded fallback is not assumed')
130
+ if len(rows) != 1:
131
+ raise AmbiguousMetadataError('Multiple table shard rules found; automatic selection is unsafe')
132
+ return rows[0]
133
+
134
+ def get_physical_table_name_by_int(self, brand_id: int, database: str, table_code: str,
135
+ shard_key: int) -> str:
136
+ """Load the table rule and route a nonnegative integer ID without hashing."""
137
+ _validate_int_key(shard_key)
138
+ rule = self._get_table_rule(brand_id, database, table_code)
139
+ return physical_table_name_by_int(rule['TableCode'], shard_key, rule['TableCount'])
140
+
141
+ def get_physical_table_name_by_hash(self, brand_id: int, database: str, table_code: str,
142
+ shard_key: str) -> str:
143
+ """Load the table rule and route a string ID using C#-compatible BKDR hash."""
144
+ _validate_hash_key(shard_key)
145
+ rule = self._get_table_rule(brand_id, database, table_code)
146
+ return physical_table_name_by_hash(rule['TableCode'], shard_key, rule['TableCount'])
147
+
148
+ def get_physical_table_name(self, brand_id: int, database: str, table_code: str,
149
+ shard_key: int | str) -> str:
150
+ """Deprecated type-dispatch interface; use an explicit algorithm instead."""
151
+ warnings.warn(
152
+ 'get_physical_table_name is deprecated; use get_physical_table_name_by_int '
153
+ 'or get_physical_table_name_by_hash',
154
+ DeprecationWarning, stacklevel=2,
155
+ )
156
+ if isinstance(shard_key, str):
157
+ return self.get_physical_table_name_by_hash(brand_id, database, table_code, shard_key)
158
+ return self.get_physical_table_name_by_int(brand_id, database, table_code, shard_key)
159
+
160
+ def close(self):
161
+ if self._closed:
162
+ return
163
+ self._closed = True
164
+ connection, self._connection = self._connection, None
165
+ if connection is not None:
166
+ try:
167
+ connection.close()
168
+ except Exception:
169
+ raise DatabaseError('Unable to close the database connection') from None
170
+
171
+ def __enter__(self):
172
+ self._ensure_open()
173
+ return self
174
+
175
+ def __exit__(self, exc_type, exc_value, traceback):
176
+ try:
177
+ self.close()
178
+ except DatabaseError:
179
+ if exc_type is None:
180
+ raise
@@ -0,0 +1,75 @@
1
+ """Strict environment selection and one-pass environment variable expansion."""
2
+ from collections.abc import Mapping
3
+ from dataclasses import dataclass, field
4
+ import os
5
+ import re
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+ from .errors import ConfigurationError
11
+
12
+ _VARIABLE = re.compile(r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}')
13
+ _IDENTIFIER = re.compile(r'[A-Za-z0-9_-]+\Z')
14
+
15
+
16
+ def _expand(value):
17
+ if not isinstance(value, str):
18
+ return value
19
+
20
+ def replace(match):
21
+ name = match.group(1)
22
+ if name not in os.environ:
23
+ raise ConfigurationError(f'Missing environment variable: {name}')
24
+ return os.environ[name]
25
+
26
+ return _VARIABLE.sub(replace, value)
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class EnvironmentConfig:
31
+ host: str
32
+ user: str
33
+ password: str = field(repr=False)
34
+ port: int = 3306
35
+ base_database: str = 'ezp-base'
36
+ brand_database: str = 'ezp-ed'
37
+ connect_timeout: int = 10
38
+ read_timeout: int = 30
39
+ write_timeout: int = 30
40
+
41
+
42
+ def load_environment(config: Mapping, env: str) -> EnvironmentConfig:
43
+ if not isinstance(env, str) or not env:
44
+ raise ConfigurationError('env must be an explicit nonempty environment name')
45
+ if not isinstance(config, Mapping):
46
+ raise ConfigurationError('Configuration must be a mapping')
47
+ environments = config.get('environments')
48
+ if not isinstance(environments, Mapping) or env not in environments:
49
+ raise ConfigurationError('Requested environment is not configured')
50
+ raw = environments[env]
51
+ if not isinstance(raw, Mapping):
52
+ raise ConfigurationError('Environment configuration must be a mapping')
53
+ allowed = EnvironmentConfig.__dataclass_fields__
54
+ if any(key not in allowed for key in raw):
55
+ raise ConfigurationError('Unknown environment configuration field')
56
+ data = {key: _expand(value) for key, value in raw.items()}
57
+ for key in ('host', 'user', 'password'):
58
+ if not isinstance(data.get(key), str) or (key != 'password' and not data[key].strip()):
59
+ raise ConfigurationError(f'{key} must be a string and is required')
60
+ for key, default in [('port', 3306), ('connect_timeout', 10), ('read_timeout', 30), ('write_timeout', 30)]:
61
+ value = data.get(key, default)
62
+ if type(value) is not int or value <= 0 or (key == 'port' and value > 65535):
63
+ raise ConfigurationError(f'{key} must be a positive integer in its valid range')
64
+ for key in ('base_database', 'brand_database'):
65
+ if key in data and (not isinstance(data[key], str) or not _IDENTIFIER.fullmatch(data[key])):
66
+ raise ConfigurationError(f'{key} must be a simple database identifier')
67
+ return EnvironmentConfig(**data)
68
+
69
+
70
+ def read_yaml(path: str | Path) -> Mapping:
71
+ try:
72
+ with open(path, encoding='utf-8') as file:
73
+ return yaml.safe_load(file)
74
+ except (OSError, UnicodeError, yaml.YAMLError):
75
+ raise ConfigurationError('Unable to read SDK YAML configuration') from None
@@ -0,0 +1,21 @@
1
+ """Public SDK errors; database driver details are deliberately not exposed."""
2
+
3
+
4
+ class SDKError(Exception):
5
+ """Base SDK error."""
6
+
7
+
8
+ class ConfigurationError(SDKError, ValueError):
9
+ """Invalid environment configuration."""
10
+
11
+
12
+ class MetadataNotFoundError(SDKError, LookupError):
13
+ """Brand, shard assignment, or table rule is missing."""
14
+
15
+
16
+ class AmbiguousMetadataError(SDKError):
17
+ """Multiple rules exist and selecting one would be unsafe."""
18
+
19
+
20
+ class DatabaseError(SDKError):
21
+ """Database connection or read failed."""
@@ -0,0 +1,61 @@
1
+ """Explicit routing algorithms compatible with the supplied C# GetCode."""
2
+ import warnings
3
+
4
+
5
+ def get_code(code: str) -> int:
6
+ """BKDR(seed=131), C# UTF-16 chars, unchecked Int32, masked positive."""
7
+ if not isinstance(code, str):
8
+ raise TypeError('code must be a string')
9
+ units = code.encode('utf-16-le', errors='surrogatepass')
10
+ value = 0
11
+ for index in range(0, len(units), 2):
12
+ char = units[index] | (units[index + 1] << 8)
13
+ value = (value * 131 + char) & 0xffffffff
14
+ return value & 0x7fffffff
15
+
16
+
17
+ def _validate_int_key(shard_key: int) -> None:
18
+ if type(shard_key) is not int:
19
+ raise TypeError('integer routing requires an int shard_key, not bool or a numeric string')
20
+ if shard_key < 0:
21
+ raise ValueError('negative integer shard keys are not supported')
22
+
23
+
24
+ def _validate_hash_key(shard_key: str) -> None:
25
+ if not isinstance(shard_key, str):
26
+ raise TypeError('hash routing requires a string shard_key; no implicit conversion is performed')
27
+
28
+
29
+ def _table_name(base_table_name: str, value: int, shard_count: int) -> str:
30
+ if not isinstance(base_table_name, str):
31
+ raise TypeError('base_table_name must be a string')
32
+ if not base_table_name.strip():
33
+ raise ValueError('base_table_name must not be empty')
34
+ if type(shard_count) is not int:
35
+ raise TypeError('shard_count must be an integer')
36
+ if shard_count <= 0:
37
+ raise ValueError('shard_count must be positive')
38
+ return f'{base_table_name}{value % shard_count + 1}'
39
+
40
+
41
+ def physical_table_name_by_int(base_table_name: str, shard_key: int, shard_count: int) -> str:
42
+ """Route a nonnegative integer ID directly by modulo, without hashing."""
43
+ _validate_int_key(shard_key)
44
+ return _table_name(base_table_name, shard_key, shard_count)
45
+
46
+
47
+ def physical_table_name_by_hash(base_table_name: str, shard_key: str, shard_count: int) -> str:
48
+ """Route a string ID using C#-compatible BKDR hash, then modulo."""
49
+ _validate_hash_key(shard_key)
50
+ return _table_name(base_table_name, get_code(shard_key), shard_count)
51
+
52
+
53
+ def physical_table_name(base_table_name: str, shard_key: int | str, shard_count: int) -> str:
54
+ """Deprecated type-dispatch interface; use an explicit algorithm instead."""
55
+ warnings.warn(
56
+ 'physical_table_name is deprecated; use physical_table_name_by_int or physical_table_name_by_hash',
57
+ DeprecationWarning, stacklevel=2,
58
+ )
59
+ if isinstance(shard_key, str):
60
+ return physical_table_name_by_hash(base_table_name, shard_key, shard_count)
61
+ return physical_table_name_by_int(base_table_name, shard_key, shard_count)
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: ezr-database-sdk
3
+ Version: 0.1.2
4
+ Summary: Synchronous read-only EZR brand database metadata and table routing SDK
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: PyMySQL>=1.0.0
8
+ Requires-Dist: PyYAML>=6.0
9
+ Provides-Extra: release
10
+ Requires-Dist: build>=1.2; extra == "release"
11
+ Requires-Dist: twine>=5.0; extra == "release"
12
+
13
+ # EZR Database SDK
14
+
15
+ 公司内部数据库元数据 SDK,提供同步、只读的 Python 接口:
16
+
17
+ - 多环境基础库配置,实例间连接隔离。
18
+ - 查询品牌的业务库连接节点和分表配置。
19
+ - 根据整数或字符串 ID 计算物理表名,兼容 C# BKDR Hash。
20
+
21
+ Python 3.10+,包名 `ezr-database-sdk`,导入名 `ezr_database_sdk`。
22
+
23
+ ## 安装与开发
24
+
25
+ ```bash
26
+ python -m venv .venv
27
+ .venv/bin/python -m pip install -e .
28
+ .venv/bin/python -m unittest discover -s tests -v
29
+ ```
30
+
31
+ 其他项目在自己的虚拟环境内安装本项目路径或已发布的 wheel:
32
+
33
+ ```bash
34
+ python -m pip install /path/to/ezr-database-sdk
35
+ ```
36
+
37
+ ## 快速使用
38
+
39
+ 复制 `database.example.yaml` 为私有的 `database.yaml`,设置选中环境的配置变量:
40
+
41
+ ```python
42
+ from ezr_database_sdk import (
43
+ EzrDatabaseSDK, physical_table_name_by_int, physical_table_name_by_hash,
44
+ )
45
+
46
+ with EzrDatabaseSDK.from_yaml("database.yaml", env="qcloud") as sdk:
47
+ rules = sdk.get_table_shards(brand_id=6812, database="crm")
48
+ nodes = sdk.get_connection_strings(brand_id=6812, database="crm")
49
+ # 计算已配置分表规则的表名:
50
+ # table = sdk.get_physical_table_name_by_int(6812, "crm", "实际逻辑表名", 123)
51
+ # hashed = sdk.get_physical_table_name_by_hash(6812, "crm", "实际逻辑表名", "123")
52
+
53
+ # 不连接数据库的纯计算接口:
54
+ assert physical_table_name_by_int("vip", 123, 16) == "vip12"
55
+ assert physical_table_name_by_hash("vip", "123", 16) == "vip3"
56
+ ```
57
+
58
+ 两种算法通过方法名显式选择,并严格检查参数类型,不自动转换;整数方法传入字符串、Hash 方法传入整数均会报错。旧的自动分派接口仅保留兼容并标记弃用,迁移说明见使用文档。
59
+
60
+ 连接串含敏感凭据,禁止打印或写入日志。建议每个线程独立创建 SDK 实例,并使用上下文管理器关闭。
61
+
62
+ 详细配置、返回字段、业务库代号、算法兼容及错误处理见 [使用文档](docs/database-sdk.md)。
63
+
64
+ ## 发布
65
+
66
+ ```bash
67
+ .venv/bin/python -m pip install -e '.[release]'
68
+ .venv/bin/python upload.py --dry-run # 预演,不修改、不上传
69
+ .venv/bin/python upload.py --build-only # 测试、构建、校验,不改版本、不上传
70
+ # 正式发布(先配置 Twine 凭据;会递增版本并实际上传):
71
+ # .venv/bin/python upload.py
72
+ ```
73
+
74
+ 版本递增规则与 `kxy/framework` 一致,补丁号超过 19 时进位。仓库、凭据和故障恢复见 [发布文档](docs/releasing.md)。
75
+
76
+ 本项目从 `testdata` 工具项目独立迁出,不依赖其脚本、私有配置或数据库凭据。
@@ -0,0 +1,9 @@
1
+ ezr_database_sdk/__init__.py,sha256=81d8F__i61m4EdDyVU0HAjPDPIVFqNuPy-7fdAK18Go,668
2
+ ezr_database_sdk/client.py,sha256=UIf72EebaADQdm2RswsVOLK3ozYXbEsNffjyW4GD4yo,7984
3
+ ezr_database_sdk/config.py,sha256=YUuAhR7lgf534pzksAqrVoU24lfjVNyoHuloz-w0wFo,2970
4
+ ezr_database_sdk/errors.py,sha256=lvqhraKDIWl0lSuUt7a00Sib01nNl0ZoaS4PtE7dljc,531
5
+ ezr_database_sdk/routing.py,sha256=lbfEKPvXz3NeTkoSF_ugIjCdFmuekTsqxf54XRdWYuE,2624
6
+ ezr_database_sdk-0.1.2.dist-info/METADATA,sha256=mNrTr9SrEj_7Ehng_KLrhMZ0UBujT4M3rVNe5v4OcSw,2981
7
+ ezr_database_sdk-0.1.2.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ ezr_database_sdk-0.1.2.dist-info/top_level.txt,sha256=ClwqI7R7PyEr_1rCm0LsbqI54vtDAR3uCO9w0A_5jPw,17
9
+ ezr_database_sdk-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ ezr_database_sdk