pymecli 0.5.5__tar.gz → 0.5.7__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.
Files changed (49) hide show
  1. {pymecli-0.5.5 → pymecli-0.5.7}/PKG-INFO +12 -1
  2. {pymecli-0.5.5 → pymecli-0.5.7}/README.md +9 -0
  3. {pymecli-0.5.5 → pymecli-0.5.7}/cli/bitget.py +15 -8
  4. pymecli-0.5.7/cli/etcd_mysql.py +162 -0
  5. {pymecli-0.5.5 → pymecli-0.5.7}/cli/fast.py +7 -11
  6. {pymecli-0.5.5 → pymecli-0.5.7}/cli/gate.py +12 -2
  7. {pymecli-0.5.5 → pymecli-0.5.7}/cli/okx.py +11 -2
  8. {pymecli-0.5.5 → pymecli-0.5.7}/cli/redis_csv.py +19 -10
  9. {pymecli-0.5.5 → pymecli-0.5.7}/cli/util.py +4 -1
  10. {pymecli-0.5.5 → pymecli-0.5.7}/core/config.py +1 -4
  11. {pymecli-0.5.5 → pymecli-0.5.7}/core/redis_client.py +5 -14
  12. {pymecli-0.5.5 → pymecli-0.5.7}/crypto/bitget.py +14 -7
  13. {pymecli-0.5.5 → pymecli-0.5.7}/crypto/gate.py +4 -2
  14. {pymecli-0.5.5 → pymecli-0.5.7}/crypto/okx.py +4 -2
  15. {pymecli-0.5.5 → pymecli-0.5.7}/pyproject.toml +4 -1
  16. pymecli-0.5.7/utils/etcd.py +39 -0
  17. {pymecli-0.5.5 → pymecli-0.5.7}/utils/mysql.py +116 -65
  18. pymecli-0.5.7/utils/pyredis.py +18 -0
  19. pymecli-0.5.5/utils/pyredis.py +0 -36
  20. {pymecli-0.5.5 → pymecli-0.5.7}/.gitignore +0 -0
  21. {pymecli-0.5.5 → pymecli-0.5.7}/api/__init__.py +0 -0
  22. {pymecli-0.5.5 → pymecli-0.5.7}/api/v1/__init__.py +0 -0
  23. {pymecli-0.5.5 → pymecli-0.5.7}/api/v1/clash.py +0 -0
  24. {pymecli-0.5.5 → pymecli-0.5.7}/api/v1/redis.py +0 -0
  25. {pymecli-0.5.5 → pymecli-0.5.7}/cli/__init__.py +0 -0
  26. {pymecli-0.5.5 → pymecli-0.5.7}/cli/dst.py +0 -0
  27. {pymecli-0.5.5 → pymecli-0.5.7}/cli/example.py +0 -0
  28. {pymecli-0.5.5 → pymecli-0.5.7}/cli/tele.py +0 -0
  29. {pymecli-0.5.5 → pymecli-0.5.7}/core/__init__.py +0 -0
  30. {pymecli-0.5.5 → pymecli-0.5.7}/core/clash.py +0 -0
  31. {pymecli-0.5.5 → pymecli-0.5.7}/crypto/__init__.py +0 -0
  32. {pymecli-0.5.5 → pymecli-0.5.7}/data/__init__.py +0 -0
  33. {pymecli-0.5.5 → pymecli-0.5.7}/data/dou_dict.py +0 -0
  34. {pymecli-0.5.5 → pymecli-0.5.7}/data/dou_list.py +0 -0
  35. {pymecli-0.5.5 → pymecli-0.5.7}/data/main.py +0 -0
  36. {pymecli-0.5.5 → pymecli-0.5.7}/data/template.yaml +0 -0
  37. {pymecli-0.5.5 → pymecli-0.5.7}/models/__init__.py +0 -0
  38. {pymecli-0.5.5 → pymecli-0.5.7}/models/douzero_model.py +0 -0
  39. {pymecli-0.5.5 → pymecli-0.5.7}/models/ocr_model.py +0 -0
  40. {pymecli-0.5.5 → pymecli-0.5.7}/models/response.py +0 -0
  41. {pymecli-0.5.5 → pymecli-0.5.7}/utils/__init__.py +0 -0
  42. {pymecli-0.5.5 → pymecli-0.5.7}/utils/elapsed.py +0 -0
  43. {pymecli-0.5.5 → pymecli-0.5.7}/utils/helper.py +0 -0
  44. {pymecli-0.5.5 → pymecli-0.5.7}/utils/logger.py +0 -0
  45. {pymecli-0.5.5 → pymecli-0.5.7}/utils/path.py +0 -0
  46. {pymecli-0.5.5 → pymecli-0.5.7}/utils/pd.py +0 -0
  47. {pymecli-0.5.5 → pymecli-0.5.7}/utils/sleep.py +0 -0
  48. {pymecli-0.5.5 → pymecli-0.5.7}/utils/text.py +0 -0
  49. {pymecli-0.5.5 → pymecli-0.5.7}/utils/toml.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pymecli
3
- Version: 0.5.5
3
+ Version: 0.5.7
4
4
  Summary: My CLI
5
5
  Project-URL: Homepage, https://pypi.org/project/pymecli
6
6
  Project-URL: Repository, https://github.com/meme2046/pymecli
@@ -13,11 +13,13 @@ Requires-Dist: arrow>=1.4.0
13
13
  Requires-Dist: babel>=2.17.0
14
14
  Requires-Dist: cowsay>=6.1
15
15
  Requires-Dist: dotenv>=0.9.9
16
+ Requires-Dist: etcd3>=0.12.0
16
17
  Requires-Dist: fastapi>=0.127.0
17
18
  Requires-Dist: ifaddr>=0.2.0
18
19
  Requires-Dist: netifaces-plus>=0.12.5
19
20
  Requires-Dist: numpy<2.0.0
20
21
  Requires-Dist: pandas>=2.3.3
22
+ Requires-Dist: protobuf<4
21
23
  Requires-Dist: pydantic-settings>=2.14.2
22
24
  Requires-Dist: pymysql>=1.1.2
23
25
  Requires-Dist: pytz>=2025.2
@@ -61,3 +63,12 @@ uv run dst cmu d:/.backups/dontstarvetogether/modoverrides.lua -o c:/.dst/save/C
61
63
 
62
64
  uv run dst cml d:/.backups/dontstarvetogether/modoverrides.lua -m 3486375086
63
65
  ```
66
+
67
+ # etcdsync
68
+
69
+ ```shell
70
+ etcdsync etcd2mysql /test -e d:/.env/pymecli.env -t kvs
71
+ etcdsync etcd2mysql "" -e d:/.env/pymecli.env -t kvs # 全量
72
+ etcdsync mysql2etcd /test -e d:/.env/pymecli.env -t kvs
73
+ etcdsync mysql2etcd -e d:/.env/pymecli.env -t kvs # 全量
74
+ ```
@@ -24,3 +24,12 @@ uv run dst cmu d:/.backups/dontstarvetogether/modoverrides.lua -o c:/.dst/save/C
24
24
 
25
25
  uv run dst cml d:/.backups/dontstarvetogether/modoverrides.lua -m 3486375086
26
26
  ```
27
+
28
+ # etcdsync
29
+
30
+ ```shell
31
+ etcdsync etcd2mysql /test -e d:/.env/pymecli.env -t kvs
32
+ etcdsync etcd2mysql "" -e d:/.env/pymecli.env -t kvs # 全量
33
+ etcdsync mysql2etcd /test -e d:/.env/pymecli.env -t kvs
34
+ etcdsync mysql2etcd -e d:/.env/pymecli.env -t kvs # 全量
35
+ ```
@@ -14,6 +14,7 @@ from crypto.bitget import (
14
14
  spot_tickers,
15
15
  )
16
16
  from utils.mysql import get_database_engine
17
+ from utils.pyredis import get_redis_client
17
18
 
18
19
  app = typer.Typer()
19
20
 
@@ -27,16 +28,22 @@ def sync(
27
28
  ):
28
29
  """同步mysql中grid数据到redis"""
29
30
  engine = get_database_engine(env_path)
31
+ redis = get_redis_client()
32
+ try:
30
33
 
31
- asyncio.run(grid_open(engine))
32
- asyncio.run(grid_close(engine))
34
+ async def _run():
35
+ await grid_open(engine, redis)
36
+ await grid_close(engine, redis)
37
+ await bitget_sf_open(engine, redis)
38
+ await bitget_sf_close(engine, redis)
39
+ await bitget_ff_open(engine, redis)
40
+ await bitget_ff_pending(engine, redis)
41
+ await bitget_ff_close(engine, redis)
33
42
 
34
- asyncio.run(bitget_sf_open(engine))
35
- asyncio.run(bitget_sf_close(engine))
36
-
37
- asyncio.run(bitget_ff_open(engine))
38
- asyncio.run(bitget_ff_pending(engine))
39
- asyncio.run(bitget_ff_close(engine))
43
+ asyncio.run(_run())
44
+ finally:
45
+ engine.dispose()
46
+ asyncio.run(redis.close())
40
47
 
41
48
 
42
49
  @app.command()
@@ -0,0 +1,162 @@
1
+ """
2
+ MySQL <-> Etcd 数据同步 CLI。
3
+
4
+ 两个子命令:
5
+ mysql2etcd 将 MySQL kvs 表的 k, v 写入 etcd
6
+ etcd2mysql 将 etcd 指定前缀的 key/value 写入 MySQL kvs 表
7
+ """
8
+
9
+ import logging
10
+ import os
11
+
12
+ import typer
13
+ from sqlalchemy import text
14
+
15
+ from utils.etcd import get_etcd_client
16
+ from utils.logger import get_logger
17
+ from utils.mysql import get_database_engine
18
+
19
+ app = typer.Typer(help="MySQL <-> Etcd 数据同步工具")
20
+ logger = get_logger(__name__, level=logging.INFO)
21
+
22
+ DEFAULT_TABLE = "kvs"
23
+
24
+
25
+ @app.command("mysql2etcd")
26
+ def mysql_to_etcd(
27
+ prefix: str = typer.Argument(
28
+ "",
29
+ help="MySQL k 列的前缀过滤(空字符串表示全量)",
30
+ ),
31
+ env_path: str = typer.Option(
32
+ ".env",
33
+ "--env",
34
+ "-e",
35
+ help="dotenv 文件路径",
36
+ ),
37
+ table: str = typer.Option(
38
+ DEFAULT_TABLE,
39
+ "--table",
40
+ "-t",
41
+ help="MySQL 表名,包含 k, v 字段",
42
+ ),
43
+ key_col: str = typer.Option(
44
+ "k",
45
+ "--key-col",
46
+ help="MySQL 中作为 etcd key 的列名",
47
+ ),
48
+ val_col: str = typer.Option(
49
+ "v",
50
+ "--val-col",
51
+ help="MySQL 中作为 etcd value 的列名",
52
+ ),
53
+ ):
54
+ """
55
+ 将 MySQL 表中的 k, v 全量同步到 etcd。
56
+
57
+ 每行记录会以 k 为 key、v 为 value 写入 etcd(覆盖已有值)。
58
+ """
59
+ engine = get_database_engine(env_path)
60
+ etcd = get_etcd_client(env_path)
61
+
62
+ try:
63
+ count = 0
64
+ with engine.connect() as conn:
65
+ if prefix:
66
+ query = text(
67
+ f"SELECT {key_col}, {val_col} FROM {table} WHERE {key_col} LIKE :p"
68
+ )
69
+ rows = conn.execute(query, {"p": f"{prefix}%"}).fetchall()
70
+ else:
71
+ query = text(f"SELECT {key_col}, {val_col} FROM {table}")
72
+ rows = conn.execute(query).fetchall()
73
+
74
+ for row in rows:
75
+ k, v = str(row[0]), str(row[1])
76
+ etcd.put(k, v)
77
+ logger.debug(f"put: {k} -> {v}")
78
+ count += 1
79
+
80
+ logger.info(f"✅ 已写入 {count} 条到 etcd")
81
+ finally:
82
+ engine.dispose()
83
+ etcd.close()
84
+
85
+
86
+ @app.command("etcd2mysql")
87
+ def etcd_to_mysql(
88
+ prefix: str = typer.Argument(
89
+ ...,
90
+ help="etcd key 前缀(空字符串表示全量)",
91
+ ),
92
+ env_path: str = typer.Option(
93
+ ".env",
94
+ "--env",
95
+ "-e",
96
+ help="dotenv 文件路径",
97
+ ),
98
+ table: str = typer.Option(
99
+ DEFAULT_TABLE,
100
+ "--table",
101
+ "-t",
102
+ help="MySQL 表名,包含 k, v 字段",
103
+ ),
104
+ key_col: str = typer.Option(
105
+ "k",
106
+ "--key-col",
107
+ help="MySQL 中作为 key 的列名",
108
+ ),
109
+ val_col: str = typer.Option(
110
+ "v",
111
+ "--val-col",
112
+ help="MySQL 中作为 value 的列名",
113
+ ),
114
+ ):
115
+ """
116
+ 将 etcd 中指定前缀的 key/value 同步到 MySQL。
117
+
118
+ 如果 k 已存在则更新 v, 不存在则新增。
119
+ """
120
+ engine = get_database_engine(env_path)
121
+ etcd = get_etcd_client(env_path)
122
+
123
+ try:
124
+ # get_prefix 不支持空前缀,全量时用 get_all
125
+ count = 0
126
+ with engine.connect() as conn:
127
+ iterator = etcd.get_all() if not prefix else etcd.get_prefix(prefix)
128
+ for value, meta in iterator:
129
+ k = meta.key.decode("utf-8")
130
+ v = value.decode("utf-8") if isinstance(value, bytes) else value
131
+
132
+ # 检查是否存在
133
+ check_sql = text(
134
+ f"SELECT id FROM {table} WHERE {key_col} = :k LIMIT 1"
135
+ )
136
+ existing = conn.execute(check_sql, {"k": k}).fetchone()
137
+
138
+ if existing:
139
+ update_sql = text(
140
+ f"UPDATE {table} SET {val_col} = :v WHERE {key_col} = :k"
141
+ )
142
+ conn.execute(update_sql, {"v": v, "k": k})
143
+ logger.debug(f"update: {k}")
144
+ else:
145
+ insert_sql = text(
146
+ f"INSERT INTO {table} ({key_col}, {val_col}) VALUES (:k, :v)"
147
+ )
148
+ conn.execute(insert_sql, {"k": k, "v": v})
149
+ logger.debug(f"insert: {k}")
150
+
151
+ count += 1
152
+
153
+ conn.commit()
154
+
155
+ logger.info(f"✅ 已同步 {count} 条到 MySQL")
156
+ finally:
157
+ engine.dispose()
158
+ etcd.close()
159
+
160
+
161
+ if __name__ == "__main__":
162
+ app()
@@ -18,11 +18,8 @@ from models.response import SuccessResponse
18
18
  @asynccontextmanager
19
19
  async def lifespan(app: FastAPI):
20
20
  """管理应用生命周期的上下文管理器"""
21
- redis_pool = redis.ConnectionPool(
22
- host=settings.REDIS_HOST,
23
- port=settings.REDIS_PORT,
24
- db=settings.REDIS_DB,
25
- password=settings.REDIS_PASSWORD,
21
+ redis_pool = redis.ConnectionPool.from_url(
22
+ settings.REDIS_URL,
26
23
  max_connections=20, # 根据需要调整最大连接数
27
24
  decode_responses=True,
28
25
  )
@@ -152,16 +149,15 @@ def run_app(
152
149
  "-p",
153
150
  help="服务器代理,传入则通过代理转换Clash订阅,比如:socks5://127.0.0.1:7897",
154
151
  ),
155
- redis_host: str = typer.Option(
152
+ redis_url: str = typer.Option(
156
153
  None,
157
- "--redis-host",
158
- "-r",
159
- help="redis host",
154
+ "--redis-url",
155
+ help="redis url, 格式: redis://[user:password@]host[:port][/db]",
160
156
  ),
161
157
  ):
162
158
  settings.reload()
163
- if redis_host is not None:
164
- settings.REDIS_HOST = redis_host
159
+ if redis_url is not None:
160
+ settings.REDIS_URL = redis_url
165
161
 
166
162
  clash_config = ClashConfig(rule, my_rule, proxy)
167
163
  init_generator(clash_config)
@@ -4,6 +4,7 @@ import typer
4
4
 
5
5
  from crypto.gate import grid_close, grid_open
6
6
  from utils.mysql import get_database_engine
7
+ from utils.pyredis import get_redis_client
7
8
 
8
9
  app = typer.Typer()
9
10
 
@@ -16,5 +17,14 @@ def rsync(
16
17
  ):
17
18
  """同步mysql中grid数据到redis"""
18
19
  engine = get_database_engine(env_path)
19
- asyncio.run(grid_open(engine))
20
- asyncio.run(grid_close(engine))
20
+ redis = get_redis_client()
21
+ try:
22
+
23
+ async def _run():
24
+ await grid_open(engine, redis)
25
+ await grid_close(engine, redis)
26
+
27
+ asyncio.run(_run())
28
+ finally:
29
+ engine.dispose()
30
+ asyncio.run(redis.close())
@@ -4,6 +4,7 @@ import typer
4
4
 
5
5
  from crypto.okx import grid_close, grid_open
6
6
  from utils.mysql import get_database_engine
7
+ from utils.pyredis import get_redis_client
7
8
 
8
9
  app = typer.Typer()
9
10
 
@@ -17,6 +18,14 @@ def sync(
17
18
  ):
18
19
  """同步mysql中grid数据到redis"""
19
20
  engine = get_database_engine(env_path)
21
+ redis = get_redis_client()
22
+ try:
20
23
 
21
- asyncio.run(grid_open(engine))
22
- asyncio.run(grid_close(engine))
24
+ async def _run():
25
+ await grid_open(engine, redis)
26
+ await grid_close(engine, redis)
27
+
28
+ asyncio.run(_run())
29
+ finally:
30
+ engine.dispose()
31
+ asyncio.run(redis.close())
@@ -86,7 +86,6 @@ async def csv_pd_redis(
86
86
  # df[col] = pd.to_datetime(df[col], format="mixed")
87
87
  # df[col] = dt_to_timestamp(df[col])
88
88
 
89
- r = get_redis_client()
90
89
  pipe = r.pipeline() # 启用 pipeline
91
90
  count = 0
92
91
 
@@ -141,7 +140,13 @@ def count(
141
140
  :type key_prefix: str
142
141
 
143
142
  """
144
- asyncio.run(count_async(key_prefix))
143
+ async def _cmd():
144
+ try:
145
+ await count_async(key_prefix)
146
+ finally:
147
+ await r.close()
148
+
149
+ asyncio.run(_cmd())
145
150
 
146
151
 
147
152
  @app.command()
@@ -173,14 +178,18 @@ def csv2redis(
173
178
  :param fp: 说明
174
179
  :type fp: csv文件路径
175
180
  """
176
- asyncio.run(
177
- csv_pd_redis(
178
- id1,
179
- id2,
180
- kp,
181
- fp,
182
- )
183
- )
181
+ async def _cmd():
182
+ try:
183
+ await csv_pd_redis(
184
+ id1,
185
+ id2,
186
+ kp,
187
+ fp,
188
+ )
189
+ finally:
190
+ await r.close()
191
+
192
+ asyncio.run(_cmd())
184
193
 
185
194
 
186
195
  @app.command()
@@ -177,7 +177,10 @@ def ipv6():
177
177
  print(f"IPv6 稳定地址: {ips}")
178
178
 
179
179
  r = get_redis_client_sync()
180
- r.set("local.IPv6", ips[0])
180
+ try:
181
+ r.set("local.IPv6", ips[0])
182
+ finally:
183
+ r.close()
181
184
  return ips
182
185
 
183
186
 
@@ -13,10 +13,7 @@ class Settings(BaseSettings):
13
13
  API_V1_STR: str = "/api/v1"
14
14
  PROJECT_NAME: str = metadata["Name"]
15
15
 
16
- REDIS_HOST: str = "192.168.123.7"
17
- REDIS_PORT: int = 6379
18
- REDIS_DB: int = 0
19
- REDIS_PASSWORD: str = ""
16
+ REDIS_URL: str = "redis://192.168.123.7:6379/0"
20
17
 
21
18
  PROJECT_DESCRIPTION: str = (
22
19
  f"{metadata['Summary']}, FastAPI提供: clash订阅转换、redis API"
@@ -8,26 +8,17 @@ import redis
8
8
  class RedisClient:
9
9
  def __init__(
10
10
  self,
11
- host=os.getenv("REDIS_HOST", "192.168.123.7"),
12
- port=int(os.getenv("REDIS_PORT", 6379)),
13
- db=int(os.getenv("REDIS_DB", 0)),
14
- password=os.getenv("REDIS_PASSWORD"),
15
- decode_responses=True,
11
+ url: str = os.getenv("REDIS_URL", "redis://192.168.123.7:6379/0"),
12
+ decode_responses: bool = True,
16
13
  ):
17
14
  """
18
15
  初始化Redis客户端
19
- :param host: Redis服务器地址
20
- :param port: Redis服务器端口
21
- :param db: 数据库编号
22
- :param password: 密码(如果需要)
16
+ :param url: Redis连接URL, 格式 redis://[user:password@]host[:port][/db]
23
17
  :param decode_responses: 是否自动解码响应(将字节转换为字符串)
24
18
  """
25
19
 
26
- self.client = redis.Redis(
27
- host=host,
28
- port=port,
29
- db=db,
30
- password=password,
20
+ self.client = redis.Redis.from_url(
21
+ url,
31
22
  decode_responses=decode_responses,
32
23
  socket_connect_timeout=5,
33
24
  socket_timeout=5,
@@ -47,7 +47,7 @@ def spot_tickers(symbols: list, proxy: str | None = None):
47
47
  tickers(url, symbols, proxy)
48
48
 
49
49
 
50
- async def bitget_sf_open(engine: Engine):
50
+ async def bitget_sf_open(engine: Engine, redis_client=None):
51
51
  query = "select * from bitget_sf where spot_open_usdt is not null and futures_open_usdt is not null and pnl is null and up_status = 0 and deleted_at is null;"
52
52
  key_prefix = "bitget_sf"
53
53
  table = "bitget_sf"
@@ -70,12 +70,13 @@ async def bitget_sf_open(engine: Engine):
70
70
  "spot_close_at": "datetime64[ns]",
71
71
  "futures_close_at": "datetime64[ns]",
72
72
  },
73
+ redis_client=redis_client,
73
74
  )
74
75
 
75
76
  logger.info(f"🚀 bitget SF open count:({row_count})")
76
77
 
77
78
 
78
- async def bitget_ff_open(engine: Engine):
79
+ async def bitget_ff_open(engine: Engine, redis_client=None):
79
80
  key_prefix = "bitget_ff"
80
81
  table = "bitget_ff"
81
82
  query = f"select * from {table} where long_open_usdt is not null and short_open_usdt is not null and pnl is null and up_status = 0 and deleted_at is null;"
@@ -98,12 +99,13 @@ async def bitget_ff_open(engine: Engine):
98
99
  "long_close_at": "datetime64[ns]",
99
100
  "short_close_at": "datetime64[ns]",
100
101
  },
102
+ redis_client=redis_client,
101
103
  )
102
104
 
103
105
  logger.info(f"💰 bitget FF open count:({row_count})")
104
106
 
105
107
 
106
- async def bitget_sf_close(engine: Engine):
108
+ async def bitget_sf_close(engine: Engine, redis_client=None):
107
109
  query = "select * from bitget_sf where pnl is not null and up_status in (0,1);"
108
110
  key_prefix = "bitget_sf"
109
111
  table = "bitget_sf"
@@ -126,12 +128,13 @@ async def bitget_sf_close(engine: Engine):
126
128
  "spot_close_at": "datetime64[ns]",
127
129
  "futures_close_at": "datetime64[ns]",
128
130
  },
131
+ redis_client=redis_client,
129
132
  )
130
133
 
131
134
  logger.info(f"🚀 bitget SF close count:({row_count})")
132
135
 
133
136
 
134
- async def bitget_ff_pending(engine: Engine):
137
+ async def bitget_ff_pending(engine: Engine, redis_client=None):
135
138
  key_prefix = "bitget_ff"
136
139
  table = "bitget_ff"
137
140
  query = f"select * from {table} where pnl is null and (long_close_at is not null or short_close_at is not null) and up_status in (1);"
@@ -154,12 +157,13 @@ async def bitget_ff_pending(engine: Engine):
154
157
  "long_close_at": "datetime64[ns]",
155
158
  "short_close_at": "datetime64[ns]",
156
159
  },
160
+ redis_client=redis_client,
157
161
  )
158
162
 
159
163
  logger.info(f"💰 bitget FF pending count:({row_count})")
160
164
 
161
165
 
162
- async def bitget_ff_close(engine: Engine):
166
+ async def bitget_ff_close(engine: Engine, redis_client=None):
163
167
  key_prefix = "bitget_ff"
164
168
  table = "bitget_ff"
165
169
  query = f"select * from {table} where pnl is not null and up_status in (0,1,2);"
@@ -182,12 +186,13 @@ async def bitget_ff_close(engine: Engine):
182
186
  "long_close_at": "datetime64[ns]",
183
187
  "short_close_at": "datetime64[ns]",
184
188
  },
189
+ redis_client=redis_client,
185
190
  )
186
191
 
187
192
  logger.info(f"💰 bitget FF close count:({row_count})")
188
193
 
189
194
 
190
- async def grid_open(engine: Engine):
195
+ async def grid_open(engine: Engine, redis_client=None):
191
196
  query = "select * from bitget where ((cost is not null or benefit is not null) and profit is null) and up_status = 0 and order_id is not null and deleted_at is null;"
192
197
  key_prefix = "bitget_grid"
193
198
  table = "bitget"
@@ -206,12 +211,13 @@ async def grid_open(engine: Engine):
206
211
  "open_at": "datetime64[ns]",
207
212
  "close_at": "datetime64[ns]",
208
213
  },
214
+ redis_client=redis_client,
209
215
  )
210
216
 
211
217
  logger.info(f"🧮 bitget grid open count:({row_count})")
212
218
 
213
219
 
214
- async def grid_close(engine: Engine):
220
+ async def grid_close(engine: Engine, redis_client=None):
215
221
  query = "select * from bitget where profit is not null and up_status in (0,1) and deleted_at is null;"
216
222
  key_prefix = "bitget_grid"
217
223
  table = "bitget"
@@ -230,6 +236,7 @@ async def grid_close(engine: Engine):
230
236
  "open_at": "datetime64[ns]",
231
237
  "close_at": "datetime64[ns]",
232
238
  },
239
+ redis_client=redis_client,
233
240
  )
234
241
 
235
242
  logger.info(f"🧮 bitget grid close count:({row_count})")
@@ -4,7 +4,7 @@ from utils import logger
4
4
  from utils.mysql import mysql_to_csv, mysql_to_redis
5
5
 
6
6
 
7
- async def grid_open(engine: Engine):
7
+ async def grid_open(engine: Engine, redis_client=None):
8
8
  query = "select * from gate where ((cost is not null or benefit is not null) and profit is null) and up_status = 0 and order_id is not null and deleted_at is null;"
9
9
  key_prefix = "gate_grid"
10
10
  table = "gate"
@@ -23,12 +23,13 @@ async def grid_open(engine: Engine):
23
23
  "open_at": "datetime64[ns]",
24
24
  "close_at": "datetime64[ns]",
25
25
  },
26
+ redis_client=redis_client,
26
27
  )
27
28
 
28
29
  logger.info(f"🧮 gate open count:({row_count})")
29
30
 
30
31
 
31
- async def grid_close(engine: Engine):
32
+ async def grid_close(engine: Engine, redis_client=None):
32
33
  query = "select * from gate where profit is not null and up_status in (0,1) and deleted_at is null;"
33
34
  key_prefix = "gate_grid"
34
35
  table = "gate"
@@ -47,6 +48,7 @@ async def grid_close(engine: Engine):
47
48
  "open_at": "datetime64[ns]",
48
49
  "close_at": "datetime64[ns]",
49
50
  },
51
+ redis_client=redis_client,
50
52
  )
51
53
  logger.info(f"🧮 gate close count:({row_count})")
52
54
 
@@ -4,7 +4,7 @@ from utils import logger
4
4
  from utils.mysql import mysql_to_redis
5
5
 
6
6
 
7
- async def grid_open(engine: Engine):
7
+ async def grid_open(engine: Engine, redis_client=None):
8
8
  query = "select * from okx_spot where ((cost is not null or benefit is not null) and profit is null) and up_status = 0 and order_id is not null and deleted_at is null;"
9
9
  key_prefix = "okx_grid"
10
10
  table = "okx_spot"
@@ -23,12 +23,13 @@ async def grid_open(engine: Engine):
23
23
  "open_at": "datetime64[ns]",
24
24
  "close_at": "datetime64[ns]",
25
25
  },
26
+ redis_client=redis_client,
26
27
  )
27
28
 
28
29
  logger.info(f"🧮 okx grid open count:({row_count})")
29
30
 
30
31
 
31
- async def grid_close(engine: Engine):
32
+ async def grid_close(engine: Engine, redis_client=None):
32
33
  query = "select * from okx_spot where profit is not null and up_status in (0,1) and deleted_at is null;"
33
34
  key_prefix = "okx_grid"
34
35
  table = "okx_spot"
@@ -47,6 +48,7 @@ async def grid_close(engine: Engine):
47
48
  "open_at": "datetime64[ns]",
48
49
  "close_at": "datetime64[ns]",
49
50
  },
51
+ redis_client=redis_client,
50
52
  )
51
53
 
52
54
  logger.info(f"🧮 okx grid close count:({row_count})")
@@ -9,6 +9,7 @@
9
9
  "babel>=2.17.0",
10
10
  "cowsay>=6.1",
11
11
  "dotenv>=0.9.9",
12
+ "etcd3>=0.12.0",
12
13
  "fastapi>=0.127.0",
13
14
  "SLPP>=1.2.3",
14
15
  "numpy<2.0.0",
@@ -30,13 +31,14 @@
30
31
  "netifaces-plus>=0.12.5",
31
32
  "ifaddr>=0.2.0",
32
33
  "telethon>=1.44.0",
34
+ "protobuf<4",
33
35
  ]
34
36
  description = "My CLI"
35
37
  license = { text = "MIT" }
36
38
  name = "pymecli"
37
39
  readme = "README.md"
38
40
  requires-python = ">=3.10"
39
- version = "0.5.5"
41
+ version = "0.5.7"
40
42
 
41
43
  [project.urls]
42
44
  Homepage = "https://pypi.org/project/pymecli"
@@ -46,6 +48,7 @@
46
48
  # baidu = "cli.baidu:app"
47
49
  bitget = "cli.bitget:app"
48
50
  dst = "cli.dst:app"
51
+ etcdsync = "cli.etcd_mysql:app"
49
52
  example = "cli.example:app"
50
53
  fast = "cli.fast:typer_app"
51
54
  gate = "cli.gate:app"
@@ -0,0 +1,39 @@
1
+ import os
2
+ from urllib.parse import urlsplit
3
+
4
+ from dotenv import load_dotenv
5
+
6
+ import etcd3
7
+
8
+ DEFAULT_ETCD_URL = "http://localhost:2379"
9
+
10
+
11
+ def _parse_etcd_url(url: str) -> tuple[str, int]:
12
+ """
13
+ 解析 ETCD_URL 为 (host, port)。
14
+
15
+ 支持格式:
16
+ http://localhost:2379
17
+ https://127.0.0.1:2379
18
+ localhost:2379
19
+ 127.0.0.1
20
+ """
21
+ if "://" not in url:
22
+ url = f"http://{url}"
23
+ parts = urlsplit(url)
24
+ host = parts.hostname or "localhost"
25
+ port = parts.port or 2379
26
+ return host, port
27
+
28
+
29
+ def get_etcd_client(env_path: str = ".env") -> "etcd3.Etcd3Client":
30
+ """
31
+ 创建 etcd v3 客户端。
32
+
33
+ 从环境变量 ETCD_URL 读取连接地址,默认 http://localhost:2379。
34
+ """
35
+ load_dotenv(env_path)
36
+ etcd_url = os.getenv("ETCD_URL", DEFAULT_ETCD_URL)
37
+ host, port = _parse_etcd_url(etcd_url)
38
+
39
+ return etcd3.client(host=host, port=port)
@@ -1,4 +1,6 @@
1
1
  import os
2
+ import re
3
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
2
4
 
3
5
  import pandas as pd
4
6
  from dotenv import load_dotenv
@@ -8,25 +10,62 @@ from utils import logger
8
10
  from utils.pd import deduplicated, dt_to_timestamp
9
11
  from utils.pyredis import get_redis_client
10
12
 
13
+ # Go go-sql-driver/mysql 专有参数,Python pymysql / SQLAlchemy 不支持
14
+ _GO_DSN_PARAMS = {
15
+ "parseTime", "loc", "timeout", "readTimeout", "writeTimeout",
16
+ "allowNativePasswords", "tls", "serverCert", "clientCert",
17
+ "clientKey", "multiStatements", "columnsWithAlias", "interpolateParams"}
18
+
19
+
20
+ def _strip_go_params(query: str) -> str:
21
+ """从 query string 中移除 Go DSN 专有参数"""
22
+ if not query:
23
+ return query
24
+ kept = [(k, v) for k, v in parse_qsl(query) if k not in _GO_DSN_PARAMS]
25
+ return urlencode(kept)
26
+
27
+
28
+ def _to_sqlalchemy_url(dsn: str) -> str:
29
+ """
30
+ 将 Go/Node 通用 MySQL DSN 转为 SQLAlchemy URL。
31
+
32
+ 输入格式(Go go-sql-driver/mysql DSN 或 Node mysql URI):
33
+ root:password@192.168.123.7:3366/bot_tx?charset=utf8mb4&parseTime=True&loc=Local
34
+ root:password@tcp(192.168.123.7:3366)/bot_tx?charset=utf8mb4
35
+ mysql://root:password@192.168.123.7:3306/mydb
36
+
37
+ 输出格式(SQLAlchemy):
38
+ mysql+pymysql://root:password@192.168.123.7:3366/bot_tx?charset=utf8mb4
39
+ """
40
+ # 如果已经是完整的 scheme:// URL,直接处理
41
+ if "://" in dsn:
42
+ parts = urlsplit(dsn)
43
+ # mysql:// → mysql+pymysql://
44
+ scheme = parts.scheme
45
+ if scheme == "mysql":
46
+ scheme = "mysql+pymysql"
47
+ return urlunsplit((scheme, parts.netloc, parts.path, _strip_go_params(parts.query), ""))
48
+
49
+ # Go DSN 格式:[user[:password]@][tcp(]host[:port][)]/db[?params]
50
+ # 去掉 tcp() 包装
51
+ dsn = re.sub(r"tcp\(([^)]+)\)", r"\1", dsn)
52
+
53
+ # user:pass@host:port/db?query → mysql+pymysql://user:pass@host:port/db?query
54
+ url = f"mysql+pymysql://{dsn}"
55
+ parts = urlsplit(url)
56
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, _strip_go_params(parts.query), ""))
57
+
11
58
 
12
59
  def get_database_engine(env_path: str = ".env") -> Engine:
13
60
  """创建数据库引擎"""
14
61
  load_dotenv(env_path)
15
- host = os.getenv("MYSQL_HOST")
16
- port = os.getenv("MYSQL_PORT")
17
- database = os.getenv("MYSQL_DATABASE")
18
- user = os.getenv("MYSQL_USER")
19
- password = os.getenv("MYSQL_PASSWORD")
20
-
21
- engine = create_engine(
22
- f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}",
23
- connect_args={
24
- # 对于MySQL,可以在连接参数中指定时区
25
- # "init_command": "SET time_zone='+00:00'", # UTC时间
26
- # 或者使用本地时区,例如:
27
- # "init_command": "SET time_zone='+08:00'", # 中国标准时间
28
- },
62
+ dsn = os.getenv(
63
+ "MYSQL_URL",
64
+ "root:@127.0.0.1:3306/?charset=utf8mb4",
29
65
  )
66
+ url = _to_sqlalchemy_url(dsn)
67
+
68
+ engine = create_engine(url, connect_args={})
30
69
 
31
70
  try:
32
71
  with engine.connect() as connection:
@@ -82,6 +121,7 @@ async def mysql_to_redis_and_csv(
82
121
  d_column_names: list[str],
83
122
  pd_dtype: dict | None = None,
84
123
  del_column_names: list[str] = ["id", "created_at", "updated_at", "deleted_at"],
124
+ redis_client=None,
85
125
  ) -> int:
86
126
  # 查询数据
87
127
  df = pd.read_sql(query, engine, dtype=pd_dtype)
@@ -110,33 +150,38 @@ async def mysql_to_redis_and_csv(
110
150
  # df[col] = dt_to_timestamp(pd.to_datetime(df[col], errors="coerce"))
111
151
 
112
152
  # 数据写入redis
113
- r = get_redis_client()
114
- pipe = r.pipeline() # 启用 pipeline
115
- count = 0
116
- n1, n2 = d_column_names
117
-
118
- for _, row in df.iterrows():
119
- idx1 = row[n1]
120
- idx2 = row[n2]
121
- if not idx1 or not idx2:
122
- raise ValueError("ERR:id行无效")
123
- id = f"{idx1}_{idx2}"
124
- key = f"{key_prefix}:{id}"
125
-
126
- # 转换行数据为字典(处理 NaN 为 None 或空字符串)
127
- row_dict = row.where(pd.notna(row), "").to_dict()
128
-
129
- # 1. 写入完整数据到 Hash(自动覆盖)
130
- pipe.hset(key, mapping=row_dict)
131
- # 2. 写入 ZSet 索引:score = 开仓时间戳(空值用 0 兜底)
132
- score = row["open_at"]
133
- if score is None or pd.isna(score):
134
- score = 0
135
- pipe.zadd(f"by_time:{key_prefix}", {id: score})
136
- count += 1
137
-
138
- await pipe.execute()
139
- logger.debug(f"🧱 to redis: {count}")
153
+ own_client = redis_client is None
154
+ r = redis_client or get_redis_client()
155
+ try:
156
+ pipe = r.pipeline() # 启用 pipeline
157
+ count = 0
158
+ n1, n2 = d_column_names
159
+
160
+ for _, row in df.iterrows():
161
+ idx1 = row[n1]
162
+ idx2 = row[n2]
163
+ if not idx1 or not idx2:
164
+ raise ValueError("ERR:id行无效")
165
+ id = f"{idx1}_{idx2}"
166
+ key = f"{key_prefix}:{id}"
167
+
168
+ # 转换行数据为字典(处理 NaN 为 None 或空字符串)
169
+ row_dict = row.where(pd.notna(row), "").to_dict()
170
+
171
+ # 1. 写入完整数据到 Hash(自动覆盖)
172
+ pipe.hset(key, mapping=row_dict)
173
+ # 2. 写入 ZSet 索引:score = 开仓时间戳(空值用 0 兜底)
174
+ score = row["open_at"]
175
+ if score is None or pd.isna(score):
176
+ score = 0
177
+ pipe.zadd(f"by_time:{key_prefix}", {id: score})
178
+ count += 1
179
+
180
+ await pipe.execute()
181
+ logger.debug(f"🧱 to redis: {count}")
182
+ finally:
183
+ if own_client:
184
+ await r.close()
140
185
 
141
186
  df.to_csv(
142
187
  csv_fp,
@@ -184,6 +229,7 @@ async def mysql_to_redis(
184
229
  d_column_names: list[str],
185
230
  pd_dtype: dict | None = None,
186
231
  del_column_names: list[str] = ["id", "created_at", "updated_at", "deleted_at"],
232
+ redis_client=None,
187
233
  ) -> int:
188
234
  df = pd.read_sql(query, engine, dtype=pd_dtype)
189
235
  df["open_at"] = df["open_at"].fillna(df["created_at"])
@@ -207,30 +253,35 @@ async def mysql_to_redis(
207
253
  if col in df.columns:
208
254
  df[col] = dt_to_timestamp(df[col])
209
255
 
210
- r = get_redis_client()
211
- pipe = r.pipeline()
212
- count = 0
213
- n1, n2 = d_column_names
214
-
215
- for _, row in df.iterrows():
216
- idx1 = row[n1]
217
- idx2 = row[n2]
218
- if not idx1 or not idx2:
219
- raise ValueError("ERR:id行无效")
220
- id = f"{idx1}_{idx2}"
221
- key = f"{key_prefix}:{id}"
222
-
223
- row_dict = row.where(pd.notna(row), "").to_dict()
224
-
225
- pipe.hset(key, mapping=row_dict)
226
- score = row["open_at"]
227
- if score is None or pd.isna(score):
228
- score = 0
229
- pipe.zadd(f"by_time:{key_prefix}", {id: score})
230
- count += 1
231
-
232
- await pipe.execute()
233
- logger.debug(f"🧱 to redis: {count}")
256
+ own_client = redis_client is None
257
+ r = redis_client or get_redis_client()
258
+ try:
259
+ pipe = r.pipeline()
260
+ count = 0
261
+ n1, n2 = d_column_names
262
+
263
+ for _, row in df.iterrows():
264
+ idx1 = row[n1]
265
+ idx2 = row[n2]
266
+ if not idx1 or not idx2:
267
+ raise ValueError("ERR:id行无效")
268
+ id = f"{idx1}_{idx2}"
269
+ key = f"{key_prefix}:{id}"
270
+
271
+ row_dict = row.where(pd.notna(row), "").to_dict()
272
+
273
+ pipe.hset(key, mapping=row_dict)
274
+ score = row["open_at"]
275
+ if score is None or pd.isna(score):
276
+ score = 0
277
+ pipe.zadd(f"by_time:{key_prefix}", {id: score})
278
+ count += 1
279
+
280
+ await pipe.execute()
281
+ logger.debug(f"🧱 to redis: {count}")
282
+ finally:
283
+ if own_client:
284
+ await r.close()
234
285
 
235
286
  return _update_status(engine, table, ids, update_status)
236
287
 
@@ -0,0 +1,18 @@
1
+ import os
2
+
3
+ import redis as redis_sync
4
+ import redis.asyncio as redis
5
+
6
+
7
+ def get_redis_client(
8
+ url: str = os.getenv("REDIS_URL", "redis://192.168.123.7:6379/0"),
9
+ decode_responses: bool = True,
10
+ ) -> redis.Redis:
11
+ return redis.Redis.from_url(url, decode_responses=decode_responses)
12
+
13
+
14
+ def get_redis_client_sync(
15
+ url: str = os.getenv("REDIS_URL", "redis://192.168.123.7:6379/0"),
16
+ decode_responses: bool = True,
17
+ ) -> redis_sync.Redis:
18
+ return redis_sync.Redis.from_url(url, decode_responses=decode_responses)
@@ -1,36 +0,0 @@
1
- import os
2
-
3
- import redis as redis_sync
4
- import redis.asyncio as redis
5
-
6
-
7
- def get_redis_client(
8
- host=os.getenv("REDIS_HOST", "192.168.123.7"),
9
- port=int(os.getenv("REDIS_PORT", 6379)),
10
- db=int(os.getenv("REDIS_DB", 0)),
11
- password=os.getenv("REDIS_PASSWORD"),
12
- decode_responses=True,
13
- ) -> redis.Redis:
14
- return redis.Redis(
15
- host=host,
16
- port=port,
17
- db=db,
18
- password=password,
19
- decode_responses=decode_responses,
20
- )
21
-
22
-
23
- def get_redis_client_sync(
24
- host=os.getenv("REDIS_HOST", "192.168.123.7"),
25
- port=int(os.getenv("REDIS_PORT", 6379)),
26
- db=int(os.getenv("REDIS_DB", 0)),
27
- password=os.getenv("REDIS_PASSWORD"),
28
- decode_responses=True,
29
- ) -> redis_sync.Redis:
30
- return redis_sync.Redis(
31
- host=host,
32
- port=port,
33
- db=db,
34
- password=password,
35
- decode_responses=decode_responses,
36
- )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes