yjdata 0.2.2__tar.gz → 0.2.4__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 (39) hide show
  1. yjdata-0.2.4/PKG-INFO +66 -0
  2. yjdata-0.2.4/README.md +55 -0
  3. {yjdata-0.2.2 → yjdata-0.2.4}/pyproject.toml +1 -1
  4. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata/db/mysql.py +43 -1
  5. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata/db/sql.py +12 -2
  6. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata/web/calendar.py +3 -3
  7. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata/web/client.py +12 -0
  8. yjdata-0.2.4/src/yjdata/web/cn_future.py +110 -0
  9. yjdata-0.2.4/src/yjdata/web/cn_stock.py +108 -0
  10. yjdata-0.2.4/src/yjdata.egg-info/PKG-INFO +66 -0
  11. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata.egg-info/SOURCES.txt +5 -1
  12. yjdata-0.2.4/tests/test_batch_writes.py +137 -0
  13. yjdata-0.2.4/tests/test_mysql_batch_updates.py +130 -0
  14. yjdata-0.2.2/PKG-INFO +0 -111
  15. yjdata-0.2.2/README.md +0 -100
  16. yjdata-0.2.2/src/yjdata.egg-info/PKG-INFO +0 -111
  17. {yjdata-0.2.2 → yjdata-0.2.4}/setup.cfg +0 -0
  18. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata/__init__.py +0 -0
  19. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata/_optional.py +0 -0
  20. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata/dataset/__init__.py +0 -0
  21. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata/dataset/common.py +0 -0
  22. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata/dataset/quote.py +0 -0
  23. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata/dataset/snapshot.py +0 -0
  24. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata/dataset/trade.py +0 -0
  25. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata/db/__init__.py +0 -0
  26. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata/db/adapter.py +0 -0
  27. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata/db/expression.py +0 -0
  28. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata/db/postgres.py +0 -0
  29. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata/dtype.py +0 -0
  30. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata/utlity/__init__.py +0 -0
  31. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata/utlity/s3.py +0 -0
  32. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata/web/__init__.py +0 -0
  33. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata/web/_context.py +0 -0
  34. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata/web/_input.py +0 -0
  35. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata/web/time_util.py +0 -0
  36. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata.egg-info/dependency_links.txt +0 -0
  37. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata.egg-info/requires.txt +0 -0
  38. {yjdata-0.2.2 → yjdata-0.2.4}/src/yjdata.egg-info/top_level.txt +0 -0
  39. {yjdata-0.2.2 → yjdata-0.2.4}/tests/test_last_of_group.py +0 -0
yjdata-0.2.4/PKG-INFO ADDED
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: yjdata
3
+ Version: 0.2.4
4
+ Summary: yjdata SDK for Python
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: httpx>=0.25
8
+ Requires-Dist: polars>=1.30
9
+ Requires-Dist: pyarrow>=18
10
+ Requires-Dist: pydantic>=2.11
11
+
12
+ # yjdata
13
+
14
+ Python SDK for the YJData web API, MySQL/PostgreSQL adapters, and Parquet utilities.
15
+
16
+ ## Web API
17
+
18
+ ```python
19
+ from yjdata import WebDataClient
20
+
21
+ client = WebDataClient()
22
+ prices = client.cn_stock.stk_eod_md("2026-11-06", ids="000001.XSHE")
23
+ for price in prices:
24
+ print(price.id, price.close)
25
+
26
+ trading_days = client.calendar.range("2026-11-01", "2026-11-06")
27
+ ```
28
+
29
+ | Method | Data |
30
+ | --- | --- |
31
+ | `cn_stock.stk_eod_md(date, ids=None, stable_id=True)` | Stock end-of-day prices |
32
+ | `cn_stock.stk_sod_ref(date, ids=None)` | Stock pre-market reference data |
33
+ | `cn_future.fut_eod_md(date, ids=None)` | Futures end-of-day prices |
34
+ | `cn_future.fut_sod_ref(date, ids=None)` | Futures pre-market reference data |
35
+ | `calendar.next_n(start, n)`, `last_n(end, n)`, `range(start, end)`, `is_trading_day(date)` | Trading calendar; optional `calendar="XSHG"` |
36
+
37
+ - Dates accept `YYYY-MM-DD`, `YYYYMMDD`, `datetime.date`, or `datetime.datetime`.
38
+ - IDs accept lists or comma-separated strings. `None` returns all records;
39
+ `[]` or a blank string returns none.
40
+ - Stock prices use first-listing IDs by default. `stable_id=False` uses the
41
+ ticker on the requested date.
42
+ - Market data returns Pydantic models with friendly fields such as `close`,
43
+ `volume`, and `display_id`; `time` is a `YYYY-MM-DD` string. Models accept
44
+ friendly names and API aliases. `model_dump(by_alias=True)` exports API names.
45
+ - Use `AsyncWebDataClient` with `await` for the same methods.
46
+
47
+ See the [OpenAPI schema](https://yjdata-api.yaojingriver.internal/openapi.json)
48
+ for endpoint and field details.
49
+
50
+ ## Database adapters
51
+
52
+ `MysqlAdapter` (MySQL 8.0+) and `PostgresAdapter` support
53
+ `select_last_of_group(table, group_by=..., sort_by=...)` and
54
+ `select_last_of_group_polars(...)`, returning dictionaries or a Polars DataFrame.
55
+
56
+ These methods select the last complete row per group in SQL. Use `ascending`
57
+ to control sort direction, `filter` before selection, `post_filter` afterward,
58
+ and `columns` for projection. NULLs sort below non-NULL values; include a unique
59
+ tie-breaker in `sort_by` for deterministic selection. Group output order is
60
+ unspecified.
61
+
62
+ ## Tests
63
+
64
+ ```sh
65
+ PYTHONPATH=src python -m unittest discover -s tests -v
66
+ ```
yjdata-0.2.4/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # yjdata
2
+
3
+ Python SDK for the YJData web API, MySQL/PostgreSQL adapters, and Parquet utilities.
4
+
5
+ ## Web API
6
+
7
+ ```python
8
+ from yjdata import WebDataClient
9
+
10
+ client = WebDataClient()
11
+ prices = client.cn_stock.stk_eod_md("2026-11-06", ids="000001.XSHE")
12
+ for price in prices:
13
+ print(price.id, price.close)
14
+
15
+ trading_days = client.calendar.range("2026-11-01", "2026-11-06")
16
+ ```
17
+
18
+ | Method | Data |
19
+ | --- | --- |
20
+ | `cn_stock.stk_eod_md(date, ids=None, stable_id=True)` | Stock end-of-day prices |
21
+ | `cn_stock.stk_sod_ref(date, ids=None)` | Stock pre-market reference data |
22
+ | `cn_future.fut_eod_md(date, ids=None)` | Futures end-of-day prices |
23
+ | `cn_future.fut_sod_ref(date, ids=None)` | Futures pre-market reference data |
24
+ | `calendar.next_n(start, n)`, `last_n(end, n)`, `range(start, end)`, `is_trading_day(date)` | Trading calendar; optional `calendar="XSHG"` |
25
+
26
+ - Dates accept `YYYY-MM-DD`, `YYYYMMDD`, `datetime.date`, or `datetime.datetime`.
27
+ - IDs accept lists or comma-separated strings. `None` returns all records;
28
+ `[]` or a blank string returns none.
29
+ - Stock prices use first-listing IDs by default. `stable_id=False` uses the
30
+ ticker on the requested date.
31
+ - Market data returns Pydantic models with friendly fields such as `close`,
32
+ `volume`, and `display_id`; `time` is a `YYYY-MM-DD` string. Models accept
33
+ friendly names and API aliases. `model_dump(by_alias=True)` exports API names.
34
+ - Use `AsyncWebDataClient` with `await` for the same methods.
35
+
36
+ See the [OpenAPI schema](https://yjdata-api.yaojingriver.internal/openapi.json)
37
+ for endpoint and field details.
38
+
39
+ ## Database adapters
40
+
41
+ `MysqlAdapter` (MySQL 8.0+) and `PostgresAdapter` support
42
+ `select_last_of_group(table, group_by=..., sort_by=...)` and
43
+ `select_last_of_group_polars(...)`, returning dictionaries or a Polars DataFrame.
44
+
45
+ These methods select the last complete row per group in SQL. Use `ascending`
46
+ to control sort direction, `filter` before selection, `post_filter` afterward,
47
+ and `columns` for projection. NULLs sort below non-NULL values; include a unique
48
+ tie-breaker in `sort_by` for deterministic selection. Group output order is
49
+ unspecified.
50
+
51
+ ## Tests
52
+
53
+ ```sh
54
+ PYTHONPATH=src python -m unittest discover -s tests -v
55
+ ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "yjdata"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "yjdata SDK for Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -51,7 +51,49 @@ class MysqlAdapter(SqlAdapter):
51
51
  return upsert_qry
52
52
 
53
53
  def _create_connection(self):
54
- return self._driver.connect(**self._conn_kwargs)
54
+ kwargs = self._conn_kwargs.copy()
55
+ # UPDATE batches send several statements in one request, in row order.
56
+ kwargs["client_flag"] = (
57
+ kwargs.get("client_flag", 0) | self._driver.constants.CLIENT.MULTI_STATEMENTS
58
+ )
59
+ return self._driver.connect(**kwargs)
60
+
61
+ def _execute_batch(
62
+ self,
63
+ cursor: Any,
64
+ qry: Any,
65
+ data: list,
66
+ batch_size: int,
67
+ ) -> None:
68
+ if not isinstance(qry, str) or not qry.startswith("UPDATE "):
69
+ # PyMySQL already combines INSERT/UPSERT values into bulk statements.
70
+ return super()._execute_batch(cursor, qry, data, batch_size)
71
+
72
+ statements: list[bytes] = []
73
+ byte_count = 0
74
+ for row in data:
75
+ # Let the driver escape values using the connection's SQL mode.
76
+ statement = cursor.mogrify(qry, row).encode(
77
+ cursor.connection.encoding, "surrogateescape"
78
+ )
79
+ if statements and (
80
+ len(statements) >= batch_size
81
+ or byte_count + 1 + len(statement) > cursor.max_stmt_length
82
+ ):
83
+ self._execute_statements(cursor, statements)
84
+ statements = []
85
+ byte_count = 0
86
+ byte_count += len(statement) + bool(statements)
87
+ statements.append(statement)
88
+ if statements:
89
+ self._execute_statements(cursor, statements)
90
+
91
+ @staticmethod
92
+ def _execute_statements(cursor: Any, statements: list[bytes]) -> None:
93
+ cursor.execute(b";".join(statements))
94
+ # Errors in later statements surface through nextset(), before commit.
95
+ while cursor.nextset():
96
+ pass
55
97
 
56
98
  def _cursor(self, connection: Any, *, as_dict: bool = False):
57
99
  if as_dict:
@@ -294,8 +294,18 @@ class SqlAdapter(DbAdapter):
294
294
  return
295
295
  with self._connection_scope(write=True) as connection:
296
296
  with self._cursor(connection) as cursor:
297
- for offset in range(0, len(data), batch_size):
298
- cursor.executemany(qry, data[offset:offset + batch_size])
297
+ self._execute_batch(cursor, qry, data, batch_size)
298
+
299
+ def _execute_batch(
300
+ self,
301
+ cursor: Any,
302
+ qry: Any,
303
+ data: list,
304
+ batch_size: int,
305
+ ) -> None:
306
+ """Execute batches, allowing adapters to use driver-specific helpers."""
307
+ for offset in range(0, len(data), batch_size):
308
+ cursor.executemany(qry, data[offset:offset + batch_size])
299
309
 
300
310
  @abstractmethod
301
311
  def _build_upsert_sql(
@@ -49,7 +49,7 @@ class CalendarWebClient:
49
49
  return self._context.post(f'{self._api_root}/next-n', payload={
50
50
  'start': parse_date_str_arg(start, False),
51
51
  'n': n,
52
- 'calendar': calendar
52
+ 'exchange': calendar
53
53
  })
54
54
 
55
55
  def last_n(
@@ -83,7 +83,7 @@ class CalendarWebClient:
83
83
  return self._context.post(f'{self._api_root}/last-n', payload={
84
84
  'end': parse_date_str_arg(end, False),
85
85
  'n': n,
86
- 'calendar': calendar
86
+ 'exchange': calendar
87
87
  })
88
88
 
89
89
  def range(
@@ -118,7 +118,7 @@ class CalendarWebClient:
118
118
  return self._context.post(f'{self._api_root}/range', payload={
119
119
  'start': parse_date_str_arg(start, False),
120
120
  'end': parse_date_str_arg(end, False),
121
- 'calendar': calendar
121
+ 'exchange': calendar
122
122
  })
123
123
 
124
124
  @abstractmethod
@@ -1,6 +1,8 @@
1
1
  import os
2
2
 
3
3
  from .calendar import SyncCalenderWebClient, AsyncCalendarWebClient
4
+ from .cn_future import CnFutureWebClient
5
+ from .cn_stock import CnStockWebClient
4
6
  from ._context import RequestContext, SyncRequestContext, AsyncRequestContext
5
7
 
6
8
 
@@ -23,6 +25,16 @@ class _WebClient:
23
25
  self._host = host
24
26
  self._context = context
25
27
 
28
+ @property
29
+ def cn_stock(self) -> CnStockWebClient:
30
+ """API set for domestic stock market data."""
31
+ return CnStockWebClient(self._host, self._context)
32
+
33
+ @property
34
+ def cn_future(self) -> CnFutureWebClient:
35
+ """API set for domestic futures market data."""
36
+ return CnFutureWebClient(self._host, self._context)
37
+
26
38
 
27
39
  class WebDataClient(_WebClient):
28
40
  """Client for accessing YJData web API."""
@@ -0,0 +1,110 @@
1
+ from typing import Awaitable, List, Union
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+ from ._context import RequestContext
6
+ from ._input import DateStrArgType, StrListArgType, parse_date_str_arg, parse_str_list_arg
7
+
8
+
9
+ __all__ = ['CnFutureWebClient', 'FutEodMd', 'FutSodRef']
10
+
11
+
12
+ class FutEodMd(BaseModel):
13
+ """Futures end-of-day prices, volume, turnover and open interest."""
14
+
15
+ model_config = ConfigDict(validate_by_name=True)
16
+
17
+ id: str
18
+ time: str = Field(alias='date')
19
+ pre_settle: float = Field(alias='ps')
20
+ pre_close: float = Field(alias='pc')
21
+ open: float = Field(alias='o')
22
+ high: float = Field(alias='h')
23
+ low: float = Field(alias='l')
24
+ close: float = Field(alias='c')
25
+ settle: float = Field(alias='s')
26
+ volume: int = Field(alias='vol')
27
+ amount: float = Field(alias='amt')
28
+ open_interest: int = Field(alias='oi')
29
+
30
+
31
+ class FutSodRef(BaseModel):
32
+ """Futures pre-market reference data, including contract specifications."""
33
+
34
+ model_config = ConfigDict(validate_by_name=True)
35
+
36
+ id: str
37
+ time: str = Field(alias='date')
38
+ name: str
39
+ display_id: str = Field(alias='dispId')
40
+ ticker: str
41
+ trading_status: str = Field(alias='status')
42
+ underlying_id: Union[str, None] = Field(alias='ulId')
43
+ product: str = Field(alias='prod')
44
+ multiplier: float = Field(alias='mult')
45
+ tick: float
46
+ pre_close: float = Field(alias='pc')
47
+ pre_settle: float = Field(alias='ps')
48
+ auct_hours: str = Field(alias='auct')
49
+ cont_hours: str = Field(alias='cont')
50
+ upper_limit: float = Field(alias='ul')
51
+ lower_limit: float = Field(alias='ll')
52
+
53
+
54
+ class CnFutureWebClient:
55
+
56
+ def __init__(self, host: str, context: RequestContext):
57
+ self._api_root = f'http://{host}/v1/cn_future'
58
+ self._context = context
59
+
60
+ def fut_eod_md(
61
+ self,
62
+ date: DateStrArgType,
63
+ ids: StrListArgType = None
64
+ ) -> Union[List[FutEodMd], Awaitable[List[FutEodMd]]]:
65
+ """Get domestic futures end-of-day market data for a trading date.
66
+
67
+ Parameters
68
+ ----------
69
+ date : str, datetime.date or datetime.datetime
70
+ Trading date; strings accept YYYY-MM-DD or YYYYMMDD.
71
+ ids : str or list[str], optional
72
+ IDs as a list or comma-separated string. None returns all records;
73
+ an empty list or blank string returns no records.
74
+
75
+ Returns
76
+ -------
77
+ list[FutEodMd] or Awaitable[list[FutEodMd]]
78
+ Validated records; await the result with AsyncWebDataClient.
79
+ """
80
+ return self._context.post_model_list(FutEodMd, f'{self._api_root}/fut-eod-md', payload={
81
+ 'date': parse_date_str_arg(date, False),
82
+ 'ids': None if ids is None else parse_str_list_arg(ids)
83
+ })
84
+
85
+ def fut_sod_ref(
86
+ self,
87
+ date: DateStrArgType,
88
+ ids: StrListArgType = None
89
+ ) -> Union[List[FutSodRef], Awaitable[List[FutSodRef]]]:
90
+ """Get domestic futures pre-market reference data for a trading date.
91
+
92
+ Parameters
93
+ ----------
94
+ date : str, datetime.date or datetime.datetime
95
+ Trading date; strings accept YYYY-MM-DD or YYYYMMDD.
96
+ ids : str or list[str], optional
97
+ IDs as a list or comma-separated string. None returns all records;
98
+ an empty list or blank string returns no records.
99
+
100
+ Returns
101
+ -------
102
+ list[FutSodRef] or Awaitable[list[FutSodRef]]
103
+ Validated records; await the result with AsyncWebDataClient.
104
+ API fields dispId and ulId use display_id and underlying_id.
105
+ underlying_id is None for contracts without an underlying index.
106
+ """
107
+ return self._context.post_model_list(FutSodRef, f'{self._api_root}/fut-sod-ref', payload={
108
+ 'date': parse_date_str_arg(date, False),
109
+ 'ids': None if ids is None else parse_str_list_arg(ids)
110
+ })
@@ -0,0 +1,108 @@
1
+ from typing import Awaitable, List, Union
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+ from ._context import RequestContext
6
+ from ._input import DateStrArgType, StrListArgType, parse_date_str_arg, parse_str_list_arg
7
+
8
+
9
+ __all__ = ['CnStockWebClient', 'StkEodMd', 'StkSodRef']
10
+
11
+
12
+ class StkEodMd(BaseModel):
13
+ """Stock end-of-day prices, volume and turnover."""
14
+
15
+ model_config = ConfigDict(validate_by_name=True)
16
+
17
+ id: str
18
+ time: str = Field(alias='date')
19
+ pre_close: float = Field(alias='pc')
20
+ open: float = Field(alias='o')
21
+ high: float = Field(alias='h')
22
+ low: float = Field(alias='l')
23
+ close: float = Field(alias='c')
24
+ volume: int = Field(alias='vol')
25
+ amount: float = Field(alias='amt')
26
+
27
+
28
+ class StkSodRef(BaseModel):
29
+ """Stock pre-market reference data. IDs use the first listing ticker."""
30
+
31
+ model_config = ConfigDict(validate_by_name=True)
32
+
33
+ id: str
34
+ time: str = Field(alias='date')
35
+ name: str
36
+ display_id: str = Field(alias='dispId')
37
+ ticker: str
38
+ trading_status: str = Field(alias='status')
39
+ st_status: str = Field(alias='st')
40
+ pre_close: float = Field(alias='pc')
41
+ prior_close: float = Field(alias='prc')
42
+ upper_limit: float = Field(alias='ul')
43
+ lower_limit: float = Field(alias='ll')
44
+ auct_hours: str = Field(alias='auct')
45
+ cont_hours: str = Field(alias='cont')
46
+
47
+
48
+ class CnStockWebClient:
49
+
50
+ def __init__(self, host: str, context: RequestContext):
51
+ self._api_root = f'http://{host}/v1/cn_stock'
52
+ self._context = context
53
+
54
+ def stk_eod_md(
55
+ self,
56
+ date: DateStrArgType,
57
+ ids: StrListArgType = None,
58
+ stable_id: bool = True
59
+ ) -> Union[List[StkEodMd], Awaitable[List[StkEodMd]]]:
60
+ """Get stock end-of-day market data for a trading date.
61
+
62
+ Parameters
63
+ ----------
64
+ date : str, datetime.date or datetime.datetime
65
+ Trading date; strings accept YYYY-MM-DD or YYYYMMDD.
66
+ ids : str or list[str], optional
67
+ IDs as a list or comma-separated string. None returns all records;
68
+ an empty list or blank string returns no records.
69
+ stable_id : bool, default: True
70
+ Use the first listing ticker plus exchange for both the ID filter
71
+ and returned IDs. False uses the ticker on the requested date.
72
+
73
+ Returns
74
+ -------
75
+ list[StkEodMd] or Awaitable[list[StkEodMd]]
76
+ Validated records; await the result with AsyncWebDataClient.
77
+ """
78
+ return self._context.post_model_list(StkEodMd, f'{self._api_root}/stk-eod-md', payload={
79
+ 'date': parse_date_str_arg(date, False),
80
+ 'ids': None if ids is None else parse_str_list_arg(ids),
81
+ 'stableId': stable_id
82
+ })
83
+
84
+ def stk_sod_ref(
85
+ self,
86
+ date: DateStrArgType,
87
+ ids: StrListArgType = None
88
+ ) -> Union[List[StkSodRef], Awaitable[List[StkSodRef]]]:
89
+ """Get stock pre-market reference data for a trading date.
90
+
91
+ Parameters
92
+ ----------
93
+ date : str, datetime.date or datetime.datetime
94
+ Trading date; strings accept YYYY-MM-DD or YYYYMMDD.
95
+ ids : str or list[str], optional
96
+ IDs as a list or comma-separated string. None returns all records;
97
+ an empty list or blank string returns no records.
98
+
99
+ Returns
100
+ -------
101
+ list[StkSodRef] or Awaitable[list[StkSodRef]]
102
+ Validated records; await the result with AsyncWebDataClient.
103
+ Fields use backend Python names, such as display_id for dispId.
104
+ """
105
+ return self._context.post_model_list(StkSodRef, f'{self._api_root}/stk-sod-ref', payload={
106
+ 'date': parse_date_str_arg(date, False),
107
+ 'ids': None if ids is None else parse_str_list_arg(ids)
108
+ })
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: yjdata
3
+ Version: 0.2.4
4
+ Summary: yjdata SDK for Python
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: httpx>=0.25
8
+ Requires-Dist: polars>=1.30
9
+ Requires-Dist: pyarrow>=18
10
+ Requires-Dist: pydantic>=2.11
11
+
12
+ # yjdata
13
+
14
+ Python SDK for the YJData web API, MySQL/PostgreSQL adapters, and Parquet utilities.
15
+
16
+ ## Web API
17
+
18
+ ```python
19
+ from yjdata import WebDataClient
20
+
21
+ client = WebDataClient()
22
+ prices = client.cn_stock.stk_eod_md("2026-11-06", ids="000001.XSHE")
23
+ for price in prices:
24
+ print(price.id, price.close)
25
+
26
+ trading_days = client.calendar.range("2026-11-01", "2026-11-06")
27
+ ```
28
+
29
+ | Method | Data |
30
+ | --- | --- |
31
+ | `cn_stock.stk_eod_md(date, ids=None, stable_id=True)` | Stock end-of-day prices |
32
+ | `cn_stock.stk_sod_ref(date, ids=None)` | Stock pre-market reference data |
33
+ | `cn_future.fut_eod_md(date, ids=None)` | Futures end-of-day prices |
34
+ | `cn_future.fut_sod_ref(date, ids=None)` | Futures pre-market reference data |
35
+ | `calendar.next_n(start, n)`, `last_n(end, n)`, `range(start, end)`, `is_trading_day(date)` | Trading calendar; optional `calendar="XSHG"` |
36
+
37
+ - Dates accept `YYYY-MM-DD`, `YYYYMMDD`, `datetime.date`, or `datetime.datetime`.
38
+ - IDs accept lists or comma-separated strings. `None` returns all records;
39
+ `[]` or a blank string returns none.
40
+ - Stock prices use first-listing IDs by default. `stable_id=False` uses the
41
+ ticker on the requested date.
42
+ - Market data returns Pydantic models with friendly fields such as `close`,
43
+ `volume`, and `display_id`; `time` is a `YYYY-MM-DD` string. Models accept
44
+ friendly names and API aliases. `model_dump(by_alias=True)` exports API names.
45
+ - Use `AsyncWebDataClient` with `await` for the same methods.
46
+
47
+ See the [OpenAPI schema](https://yjdata-api.yaojingriver.internal/openapi.json)
48
+ for endpoint and field details.
49
+
50
+ ## Database adapters
51
+
52
+ `MysqlAdapter` (MySQL 8.0+) and `PostgresAdapter` support
53
+ `select_last_of_group(table, group_by=..., sort_by=...)` and
54
+ `select_last_of_group_polars(...)`, returning dictionaries or a Polars DataFrame.
55
+
56
+ These methods select the last complete row per group in SQL. Use `ascending`
57
+ to control sort direction, `filter` before selection, `post_filter` afterward,
58
+ and `columns` for projection. NULLs sort below non-NULL values; include a unique
59
+ tie-breaker in `sort_by` for deterministic selection. Group output order is
60
+ unspecified.
61
+
62
+ ## Tests
63
+
64
+ ```sh
65
+ PYTHONPATH=src python -m unittest discover -s tests -v
66
+ ```
@@ -26,5 +26,9 @@ src/yjdata/web/_context.py
26
26
  src/yjdata/web/_input.py
27
27
  src/yjdata/web/calendar.py
28
28
  src/yjdata/web/client.py
29
+ src/yjdata/web/cn_future.py
30
+ src/yjdata/web/cn_stock.py
29
31
  src/yjdata/web/time_util.py
30
- tests/test_last_of_group.py
32
+ tests/test_batch_writes.py
33
+ tests/test_last_of_group.py
34
+ tests/test_mysql_batch_updates.py
@@ -0,0 +1,137 @@
1
+ """Verify batch dispatch and transaction handling without database servers."""
2
+
3
+ import unittest
4
+ from types import SimpleNamespace
5
+ from unittest.mock import MagicMock, Mock, call, sentinel
6
+
7
+ import polars as pl
8
+
9
+ from yjdata.db import MysqlAdapter, PostgresAdapter
10
+ from yjdata.db.sql import SqlAdapter
11
+
12
+
13
+ def make_adapter(adapter_type):
14
+ adapter = adapter_type.__new__(adapter_type)
15
+ SqlAdapter.__init__(adapter)
16
+ connection = MagicMock()
17
+ connection.closed = False
18
+ connection.open = True
19
+ cursor = connection.cursor.return_value.__enter__.return_value
20
+ adapter._create_connection = Mock(return_value=connection)
21
+ adapter._driver = SimpleNamespace(
22
+ extras=SimpleNamespace(execute_batch=Mock()),
23
+ )
24
+ # SQL construction is independent of batch dispatch. Keep the query opaque
25
+ # to also verify that PostgreSQL composable queries can pass through intact.
26
+ adapter._build_upsert_sql = Mock(return_value=sentinel.upsert_query)
27
+ return adapter, connection, cursor
28
+
29
+
30
+ class BatchWriteTests(unittest.TestCase):
31
+ def setUp(self):
32
+ self.df = pl.DataFrame({"id": [1, 2, 3, 4, 5], "value": ["a", None, "c", "d", "e"]})
33
+
34
+ def write(self, adapter, operation, df=None, batch_size=2):
35
+ df = self.df if df is None else df
36
+ if operation == "insert_data":
37
+ return adapter.insert_data("records", df.to_dicts(), batch_size=batch_size)
38
+ method = getattr(adapter, operation)
39
+ if operation == "insert_polars":
40
+ return method("records", df, batch_size=batch_size)
41
+ return method("records", ["id"], df, batch_size=batch_size)
42
+
43
+ def test_postgres_writes_use_driver_batch_helper(self):
44
+ for operation in ("insert_data", "insert_polars", "upsert_polars", "update_polars"):
45
+ for batch_size in (1, 2, 10):
46
+ with self.subTest(operation=operation, batch_size=batch_size):
47
+ adapter, connection, cursor = make_adapter(PostgresAdapter)
48
+ self.assertIsNone(self.write(adapter, operation, batch_size=batch_size))
49
+ execute_batch = adapter._driver.extras.execute_batch
50
+ execute_batch.assert_called_once()
51
+ args, kwargs = execute_batch.call_args
52
+ self.assertIs(args[0], cursor)
53
+ expected_rows = self.df.rows()
54
+ if operation == "update_polars":
55
+ expected_rows = self.df.select("value", "id").rows()
56
+ self.assertEqual(args[2], expected_rows)
57
+ self.assertEqual(kwargs, {"page_size": batch_size})
58
+ if operation == "upsert_polars":
59
+ self.assertIs(args[1], sentinel.upsert_query)
60
+ cursor.execute.assert_not_called()
61
+ cursor.executemany.assert_not_called()
62
+ connection.commit.assert_called_once_with()
63
+ connection.rollback.assert_not_called()
64
+ connection.cursor.return_value.__exit__.assert_called_once()
65
+
66
+ def test_postgres_batch_failure_rolls_back_and_propagates(self):
67
+ for operation in ("insert_polars", "upsert_polars", "update_polars"):
68
+ with self.subTest(operation=operation):
69
+ adapter, connection, cursor = make_adapter(PostgresAdapter)
70
+ error = RuntimeError("batch failed")
71
+ adapter._driver.extras.execute_batch.side_effect = error
72
+ with self.assertRaises(RuntimeError) as raised:
73
+ self.write(adapter, operation)
74
+ self.assertIs(raised.exception, error)
75
+ connection.commit.assert_not_called()
76
+ connection.rollback.assert_called_once_with()
77
+ cursor.executemany.assert_not_called()
78
+ connection.cursor.return_value.__exit__.assert_called_once()
79
+
80
+ def test_empty_writes_do_not_connect(self):
81
+ for adapter_type in (PostgresAdapter, MysqlAdapter):
82
+ for operation in ("insert_data", "insert_polars", "upsert_polars", "update_polars"):
83
+ with self.subTest(adapter=adapter_type.__name__, operation=operation):
84
+ adapter, _, _ = make_adapter(adapter_type)
85
+ self.write(adapter, operation, self.df.head(0))
86
+ adapter._create_connection.assert_not_called()
87
+ adapter._driver.extras.execute_batch.assert_not_called()
88
+
89
+ def test_mysql_keeps_executemany_batches_in_one_transaction(self):
90
+ for operation in ("insert_data", "insert_polars", "upsert_polars"):
91
+ with self.subTest(operation=operation):
92
+ adapter, connection, cursor = make_adapter(MysqlAdapter)
93
+ self.write(adapter, operation)
94
+ rows = self.df.rows()
95
+ qry = cursor.executemany.call_args_list[0].args[0]
96
+ self.assertEqual(cursor.executemany.call_args_list, [
97
+ call(qry, rows[:2]), call(qry, rows[2:4]), call(qry, rows[4:]),
98
+ ])
99
+ adapter._driver.extras.execute_batch.assert_not_called()
100
+ connection.commit.assert_called_once_with()
101
+ connection.rollback.assert_not_called()
102
+
103
+ def test_mysql_updates_batch_requests_and_drain_results(self):
104
+ adapter, connection, cursor = make_adapter(MysqlAdapter)
105
+ cursor.connection.encoding = "utf8"
106
+ cursor.max_stmt_length = 1024000
107
+ statements = [f"UPDATE records SET value = {i}" for i in range(5)]
108
+ cursor.mogrify.side_effect = statements
109
+ cursor.nextset.side_effect = [True, None, True, None, None]
110
+ self.write(adapter, "update_polars")
111
+ self.assertEqual(cursor.execute.call_args_list, [
112
+ call(";".join(statements[:2]).encode()),
113
+ call(";".join(statements[2:4]).encode()),
114
+ call(statements[4].encode()),
115
+ ])
116
+ self.assertEqual(cursor.nextset.call_count, 5)
117
+ cursor.executemany.assert_not_called()
118
+ connection.commit.assert_called_once_with()
119
+ connection.rollback.assert_not_called()
120
+
121
+ def test_mysql_later_statement_error_rolls_back(self):
122
+ adapter, connection, cursor = make_adapter(MysqlAdapter)
123
+ cursor.connection.encoding = "utf8"
124
+ cursor.max_stmt_length = 1024000
125
+ cursor.mogrify.return_value = "UPDATE records SET value = 1"
126
+ error = RuntimeError("second update failed")
127
+ cursor.nextset.side_effect = error
128
+ with self.assertRaises(RuntimeError) as raised:
129
+ self.write(adapter, "update_polars")
130
+ self.assertIs(raised.exception, error)
131
+ cursor.execute.assert_called_once()
132
+ connection.commit.assert_not_called()
133
+ connection.rollback.assert_called_once_with()
134
+
135
+
136
+ if __name__ == "__main__":
137
+ unittest.main()
@@ -0,0 +1,130 @@
1
+ """Exercise real PyMySQL escaping and cursors, replacing only server I/O."""
2
+
3
+ import unittest
4
+ from types import SimpleNamespace
5
+ from unittest.mock import Mock, patch
6
+
7
+ import polars as pl
8
+
9
+ from yjdata.db import MysqlAdapter
10
+ from yjdata.db.sql import SqlAdapter
11
+
12
+ try:
13
+ import pymysql
14
+ except ImportError:
15
+ pymysql = None
16
+
17
+
18
+ @unittest.skipIf(pymysql is None, "requires optional pymysql driver")
19
+ class MysqlBatchUpdateDriverTests(unittest.TestCase):
20
+ def setUp(self):
21
+ self.adapter = MysqlAdapter(host="localhost")
22
+ self.connection = pymysql.connect(defer_connect=True)
23
+ self.connection.server_status = 0
24
+ self.cursor = self.connection.cursor()
25
+ self.connection.cursor = Mock(return_value=self.cursor)
26
+ self.connection.commit = Mock()
27
+ self.connection.rollback = Mock()
28
+ self.connection.query = Mock(side_effect=self.query)
29
+ self.adapter._create_connection = Mock(return_value=self.connection)
30
+ self.adapter._is_connection_closed = Mock(return_value=False)
31
+
32
+ def query(self, sql, **kwargs):
33
+ self.connection._result = SimpleNamespace(
34
+ affected_rows=1, warning_count=0, description=None,
35
+ insert_id=0, rows=None, has_next=False,
36
+ )
37
+ return 1
38
+
39
+ def requests(self):
40
+ return [call.args[0] for call in self.connection.query.call_args_list]
41
+
42
+ def test_thousand_updates_use_ten_requests(self):
43
+ df = pl.DataFrame({"id": range(1000), "value": range(1000)})
44
+ with patch.object(MysqlAdapter, "_execute_batch", SqlAdapter._execute_batch):
45
+ self.adapter.update_polars("records", ["id"], df, batch_size=100)
46
+ self.assertEqual(len(self.requests()), 1000)
47
+ self.connection.query.reset_mock()
48
+ self.connection.commit.reset_mock()
49
+ self.cursor = pymysql.cursors.Cursor(self.connection)
50
+ self.connection.cursor.return_value = self.cursor
51
+ self.adapter.update_polars("records", ["id"], df, batch_size=100)
52
+ requests = self.requests()
53
+ self.assertEqual(len(requests), 10)
54
+ self.assertTrue(all(request.count(b"UPDATE ") == 100 for request in requests))
55
+ self.connection.commit.assert_called_once_with()
56
+
57
+ def test_composite_keys_nulls_duplicates_and_escaping_preserve_order(self):
58
+ # Include SQL-looking text, non-ASCII, quotes, backslashes and percent
59
+ # signs to catch unsafe joining or formatting escaped parameters twice.
60
+ values = ["中文'; UPDATE records SET v=0; -- \\ %s", None, "last"]
61
+ df = pl.DataFrame({"id": [1, 2, 1], "region": ["a", None, "a"], "value%": values})
62
+ qry = "UPDATE `records` SET `value%%` = %s WHERE `id` = %s AND `region` = %s"
63
+ for sql_mode in (0, pymysql.constants.SERVER_STATUS.SERVER_STATUS_NO_BACKSLASH_ESCAPES):
64
+ with self.subTest(sql_mode=sql_mode):
65
+ self.connection.server_status = sql_mode
66
+ self.connection.query.reset_mock()
67
+ # A fresh cursor is needed because update_polars closes it.
68
+ self.cursor = pymysql.cursors.Cursor(self.connection)
69
+ self.connection.cursor.return_value = self.cursor
70
+ expected = [self.cursor.mogrify(qry, row).encode("utf8")
71
+ for row in df.select("value%", "id", "region").rows()]
72
+ self.adapter.update_polars("records", ["id", "region"], df, batch_size=2)
73
+ self.assertEqual(self.requests(), [b";".join(expected[:2]), expected[2]])
74
+ self.assertIn(b"= NULL", self.requests()[0])
75
+
76
+ def test_statement_size_limit_counts_encoded_bytes(self):
77
+ df = pl.DataFrame({"id": [1, 2, 3], "value": ["中文"] * 3})
78
+ qry = "UPDATE `records` SET `value` = %s WHERE `id` = %s"
79
+ statements = [self.cursor.mogrify(qry, ("中文", i)).encode("utf8") for i in (1, 2, 3)]
80
+ # Two statements fit as characters, but not as encoded bytes.
81
+ self.cursor.max_stmt_length = len(b";".join(statements[:2])) - 1
82
+ self.adapter.update_polars("records", ["id"], df)
83
+ self.assertEqual(self.requests(), statements)
84
+
85
+ def test_exact_size_boundary_allows_two_statements(self):
86
+ df = pl.DataFrame({"id": [1, 2, 3], "value": ["a"] * 3})
87
+ qry = "UPDATE `records` SET `value` = %s WHERE `id` = %s"
88
+ statements = [self.cursor.mogrify(qry, ("a", i)).encode("utf8") for i in (1, 2, 3)]
89
+ self.cursor.max_stmt_length = len(b";".join(statements[:2]))
90
+ self.adapter.update_polars("records", ["id"], df)
91
+ self.assertEqual(self.requests(), [b";".join(statements[:2]), statements[2]])
92
+
93
+ def test_single_large_statement_is_sent_alone(self):
94
+ self.cursor.max_stmt_length = 1
95
+ df = pl.DataFrame({"id": [1, 2], "value": ["a", "b"]})
96
+ self.adapter.update_polars("records", ["id"], df)
97
+ self.assertEqual(len(self.requests()), 2)
98
+ self.assertTrue(all(request.startswith(b"UPDATE ") for request in self.requests()))
99
+
100
+ def test_error_in_later_result_prevents_commit(self):
101
+ error = pymysql.err.IntegrityError(1062, "duplicate entry")
102
+
103
+ def query_with_more_results(sql, **kwargs):
104
+ self.query(sql, **kwargs)
105
+ self.connection._result.has_next = True
106
+ return 1
107
+
108
+ self.connection.query.side_effect = query_with_more_results
109
+ self.connection.next_result = Mock(side_effect=error)
110
+ df = pl.DataFrame({"id": [1, 2, 3], "value": ["a", "b", "c"]})
111
+ with self.assertRaises(pymysql.err.IntegrityError) as raised:
112
+ self.adapter.update_polars("records", ["id"], df, batch_size=2)
113
+ self.assertIs(raised.exception, error)
114
+ self.assertEqual(len(self.requests()), 1)
115
+ self.connection.commit.assert_not_called()
116
+ self.connection.rollback.assert_called_once_with()
117
+
118
+ def test_connection_enables_multi_statements_and_preserves_flags(self):
119
+ for flags in (0, pymysql.constants.CLIENT.FOUND_ROWS):
120
+ with self.subTest(flags=flags):
121
+ adapter = MysqlAdapter(host="localhost", client_flag=flags)
122
+ with patch.object(pymysql, "connect") as connect:
123
+ adapter._create_connection()
124
+ self.assertEqual(connect.call_args.kwargs["client_flag"],
125
+ flags | pymysql.constants.CLIENT.MULTI_STATEMENTS)
126
+ self.assertEqual(adapter._conn_kwargs["client_flag"], flags)
127
+
128
+
129
+ if __name__ == "__main__":
130
+ unittest.main()
yjdata-0.2.2/PKG-INFO DELETED
@@ -1,111 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: yjdata
3
- Version: 0.2.2
4
- Summary: yjdata SDK for Python
5
- Requires-Python: >=3.10
6
- Description-Content-Type: text/markdown
7
- Requires-Dist: httpx>=0.25
8
- Requires-Dist: polars>=1.30
9
- Requires-Dist: pyarrow>=18
10
- Requires-Dist: pydantic>=2.11
11
-
12
- # yjdata
13
-
14
- Python SDK for yjdata, including MySQL and PostgreSQL database adapters.
15
-
16
- ## Select the last row of each group
17
-
18
- `MysqlAdapter` (MySQL 8.0+) and `PostgresAdapter` provide:
19
-
20
- - `select_last_of_group(...) -> list[dict[str, Any]]`
21
- - `select_last_of_group_polars(...) -> polars.DataFrame`
22
-
23
- Both methods execute grouping, row selection, filtering, and projection in SQL
24
- using `ROW_NUMBER()`. They accept the table name followed by keyword arguments:
25
-
26
- | Parameter | Meaning |
27
- | --- | --- |
28
- | `table` | Table name, optionally qualified by schema or database. |
29
- | `group_by: Sequence[str]` | Required nonempty sequence of grouping columns. |
30
- | `sort_by: Sequence[str]` | Required nonempty sequence of sort columns, in priority order. |
31
- | `ascending: bool \| Sequence[bool] = True` | One direction for every sort column, or one boolean per column. |
32
- | `columns: Sequence[str] \| None = None` | Nonempty sequence of output columns; `None` returns all original columns. |
33
- | `filter: Expression \| None = None` | Filter source rows before choosing a row per group. |
34
- | `post_filter: Expression \| None = None` | Filter the chosen rows, before projecting output columns. |
35
-
36
- The operation first applies `filter`, orders each group, selects its last complete
37
- source row, applies `post_filter`, and finally projects `columns`. Values come
38
- from the same row, including NULL values; this is not a separate last-non-NULL
39
- aggregation for each column. A post-filter rejection removes the group entirely,
40
- without falling back to an older row.
41
-
42
- NULLs sort below all non-NULL values on both backends: first for ascending order
43
- and last for descending order. Consequently, ascending selection prefers the
44
- largest non-NULL value, while descending selection prefers NULL when present.
45
- This rule applies to each sort column. NULL grouping keys group together.
46
- For deterministic selection among ties, include a unique column in `sort_by`,
47
- such as `sort_by=["change_time", "record_id"]`. Output order across groups is
48
- unspecified.
49
-
50
- Group, sort, and filter columns need not appear in `columns`. Internal ranking
51
- columns are excluded by the SQL projection, even if the table has similarly
52
- named columns. With `columns=None`, an additional zero-row query reads column
53
- metadata so the adapter can explicitly project all original columns.
54
- An empty result is `[]` for dictionaries or a DataFrame with no columns for
55
- Polars, matching the existing selection methods. Polars infers types from all
56
- returned rows.
57
-
58
- ### THS concept snapshots
59
-
60
- For an existing adapter `client` and a cutoff value `cutoff`:
61
-
62
- ```python
63
- import yjdata.db.expression as ye
64
-
65
- data = client.select_last_of_group_polars(
66
- table="thsetl.concept_change_record",
67
- group_by=["concept_id"],
68
- sort_by=["change_time"],
69
- ascending=True,
70
- columns=["concept_id", "concept_name"],
71
- filter=(
72
- (ye.col("change_time") <= cutoff)
73
- & (ye.col("isvalid") == "1")
74
- ),
75
- post_filter=ye.col("change_type") != "conceptNegation",
76
- )
77
- ```
78
-
79
- This selects the latest valid change at or before the cutoff, then excludes
80
- concepts whose selected change is a negation. The MySQL query has this form:
81
-
82
- ```sql
83
- SELECT `concept_id`, `concept_name`
84
- FROM (
85
- SELECT `concept_id`, `concept_name`, `change_type`,
86
- ROW_NUMBER() OVER (
87
- PARTITION BY `concept_id`
88
- ORDER BY (`change_time` IS NULL) ASC, `change_time` DESC
89
- ) AS `_yjdata_row_number`
90
- FROM `thsetl`.`concept_change_record`
91
- WHERE (`change_time` <= %s) AND (`isvalid` = %s)
92
- ) AS `_yjdata_latest`
93
- WHERE `_yjdata_row_number` = 1 AND (`change_type` <> %s)
94
- ```
95
-
96
- Values are bound in order: `[cutoff, "1", "conceptNegation"]`. PostgreSQL uses
97
- the same query structure with double-quoted identifiers. Expressions retain
98
- normal SQL NULL semantics: for example, `change_type <> 'conceptNegation'`
99
- also excludes NULL `change_type` values.
100
-
101
- ## Tests
102
-
103
- ```sh
104
- python -m unittest discover -s tests -v
105
- ```
106
-
107
- The grouped-selection tests exercise both adapters' validation, quoting,
108
- parameter ordering, and result conversion. Generated queries execute against
109
- SQLite's window engine with DB-API placeholders translated, so tests require
110
- neither optional database drivers nor running servers. This does not replace
111
- integration testing against MySQL and PostgreSQL.
yjdata-0.2.2/README.md DELETED
@@ -1,100 +0,0 @@
1
- # yjdata
2
-
3
- Python SDK for yjdata, including MySQL and PostgreSQL database adapters.
4
-
5
- ## Select the last row of each group
6
-
7
- `MysqlAdapter` (MySQL 8.0+) and `PostgresAdapter` provide:
8
-
9
- - `select_last_of_group(...) -> list[dict[str, Any]]`
10
- - `select_last_of_group_polars(...) -> polars.DataFrame`
11
-
12
- Both methods execute grouping, row selection, filtering, and projection in SQL
13
- using `ROW_NUMBER()`. They accept the table name followed by keyword arguments:
14
-
15
- | Parameter | Meaning |
16
- | --- | --- |
17
- | `table` | Table name, optionally qualified by schema or database. |
18
- | `group_by: Sequence[str]` | Required nonempty sequence of grouping columns. |
19
- | `sort_by: Sequence[str]` | Required nonempty sequence of sort columns, in priority order. |
20
- | `ascending: bool \| Sequence[bool] = True` | One direction for every sort column, or one boolean per column. |
21
- | `columns: Sequence[str] \| None = None` | Nonempty sequence of output columns; `None` returns all original columns. |
22
- | `filter: Expression \| None = None` | Filter source rows before choosing a row per group. |
23
- | `post_filter: Expression \| None = None` | Filter the chosen rows, before projecting output columns. |
24
-
25
- The operation first applies `filter`, orders each group, selects its last complete
26
- source row, applies `post_filter`, and finally projects `columns`. Values come
27
- from the same row, including NULL values; this is not a separate last-non-NULL
28
- aggregation for each column. A post-filter rejection removes the group entirely,
29
- without falling back to an older row.
30
-
31
- NULLs sort below all non-NULL values on both backends: first for ascending order
32
- and last for descending order. Consequently, ascending selection prefers the
33
- largest non-NULL value, while descending selection prefers NULL when present.
34
- This rule applies to each sort column. NULL grouping keys group together.
35
- For deterministic selection among ties, include a unique column in `sort_by`,
36
- such as `sort_by=["change_time", "record_id"]`. Output order across groups is
37
- unspecified.
38
-
39
- Group, sort, and filter columns need not appear in `columns`. Internal ranking
40
- columns are excluded by the SQL projection, even if the table has similarly
41
- named columns. With `columns=None`, an additional zero-row query reads column
42
- metadata so the adapter can explicitly project all original columns.
43
- An empty result is `[]` for dictionaries or a DataFrame with no columns for
44
- Polars, matching the existing selection methods. Polars infers types from all
45
- returned rows.
46
-
47
- ### THS concept snapshots
48
-
49
- For an existing adapter `client` and a cutoff value `cutoff`:
50
-
51
- ```python
52
- import yjdata.db.expression as ye
53
-
54
- data = client.select_last_of_group_polars(
55
- table="thsetl.concept_change_record",
56
- group_by=["concept_id"],
57
- sort_by=["change_time"],
58
- ascending=True,
59
- columns=["concept_id", "concept_name"],
60
- filter=(
61
- (ye.col("change_time") <= cutoff)
62
- & (ye.col("isvalid") == "1")
63
- ),
64
- post_filter=ye.col("change_type") != "conceptNegation",
65
- )
66
- ```
67
-
68
- This selects the latest valid change at or before the cutoff, then excludes
69
- concepts whose selected change is a negation. The MySQL query has this form:
70
-
71
- ```sql
72
- SELECT `concept_id`, `concept_name`
73
- FROM (
74
- SELECT `concept_id`, `concept_name`, `change_type`,
75
- ROW_NUMBER() OVER (
76
- PARTITION BY `concept_id`
77
- ORDER BY (`change_time` IS NULL) ASC, `change_time` DESC
78
- ) AS `_yjdata_row_number`
79
- FROM `thsetl`.`concept_change_record`
80
- WHERE (`change_time` <= %s) AND (`isvalid` = %s)
81
- ) AS `_yjdata_latest`
82
- WHERE `_yjdata_row_number` = 1 AND (`change_type` <> %s)
83
- ```
84
-
85
- Values are bound in order: `[cutoff, "1", "conceptNegation"]`. PostgreSQL uses
86
- the same query structure with double-quoted identifiers. Expressions retain
87
- normal SQL NULL semantics: for example, `change_type <> 'conceptNegation'`
88
- also excludes NULL `change_type` values.
89
-
90
- ## Tests
91
-
92
- ```sh
93
- python -m unittest discover -s tests -v
94
- ```
95
-
96
- The grouped-selection tests exercise both adapters' validation, quoting,
97
- parameter ordering, and result conversion. Generated queries execute against
98
- SQLite's window engine with DB-API placeholders translated, so tests require
99
- neither optional database drivers nor running servers. This does not replace
100
- integration testing against MySQL and PostgreSQL.
@@ -1,111 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: yjdata
3
- Version: 0.2.2
4
- Summary: yjdata SDK for Python
5
- Requires-Python: >=3.10
6
- Description-Content-Type: text/markdown
7
- Requires-Dist: httpx>=0.25
8
- Requires-Dist: polars>=1.30
9
- Requires-Dist: pyarrow>=18
10
- Requires-Dist: pydantic>=2.11
11
-
12
- # yjdata
13
-
14
- Python SDK for yjdata, including MySQL and PostgreSQL database adapters.
15
-
16
- ## Select the last row of each group
17
-
18
- `MysqlAdapter` (MySQL 8.0+) and `PostgresAdapter` provide:
19
-
20
- - `select_last_of_group(...) -> list[dict[str, Any]]`
21
- - `select_last_of_group_polars(...) -> polars.DataFrame`
22
-
23
- Both methods execute grouping, row selection, filtering, and projection in SQL
24
- using `ROW_NUMBER()`. They accept the table name followed by keyword arguments:
25
-
26
- | Parameter | Meaning |
27
- | --- | --- |
28
- | `table` | Table name, optionally qualified by schema or database. |
29
- | `group_by: Sequence[str]` | Required nonempty sequence of grouping columns. |
30
- | `sort_by: Sequence[str]` | Required nonempty sequence of sort columns, in priority order. |
31
- | `ascending: bool \| Sequence[bool] = True` | One direction for every sort column, or one boolean per column. |
32
- | `columns: Sequence[str] \| None = None` | Nonempty sequence of output columns; `None` returns all original columns. |
33
- | `filter: Expression \| None = None` | Filter source rows before choosing a row per group. |
34
- | `post_filter: Expression \| None = None` | Filter the chosen rows, before projecting output columns. |
35
-
36
- The operation first applies `filter`, orders each group, selects its last complete
37
- source row, applies `post_filter`, and finally projects `columns`. Values come
38
- from the same row, including NULL values; this is not a separate last-non-NULL
39
- aggregation for each column. A post-filter rejection removes the group entirely,
40
- without falling back to an older row.
41
-
42
- NULLs sort below all non-NULL values on both backends: first for ascending order
43
- and last for descending order. Consequently, ascending selection prefers the
44
- largest non-NULL value, while descending selection prefers NULL when present.
45
- This rule applies to each sort column. NULL grouping keys group together.
46
- For deterministic selection among ties, include a unique column in `sort_by`,
47
- such as `sort_by=["change_time", "record_id"]`. Output order across groups is
48
- unspecified.
49
-
50
- Group, sort, and filter columns need not appear in `columns`. Internal ranking
51
- columns are excluded by the SQL projection, even if the table has similarly
52
- named columns. With `columns=None`, an additional zero-row query reads column
53
- metadata so the adapter can explicitly project all original columns.
54
- An empty result is `[]` for dictionaries or a DataFrame with no columns for
55
- Polars, matching the existing selection methods. Polars infers types from all
56
- returned rows.
57
-
58
- ### THS concept snapshots
59
-
60
- For an existing adapter `client` and a cutoff value `cutoff`:
61
-
62
- ```python
63
- import yjdata.db.expression as ye
64
-
65
- data = client.select_last_of_group_polars(
66
- table="thsetl.concept_change_record",
67
- group_by=["concept_id"],
68
- sort_by=["change_time"],
69
- ascending=True,
70
- columns=["concept_id", "concept_name"],
71
- filter=(
72
- (ye.col("change_time") <= cutoff)
73
- & (ye.col("isvalid") == "1")
74
- ),
75
- post_filter=ye.col("change_type") != "conceptNegation",
76
- )
77
- ```
78
-
79
- This selects the latest valid change at or before the cutoff, then excludes
80
- concepts whose selected change is a negation. The MySQL query has this form:
81
-
82
- ```sql
83
- SELECT `concept_id`, `concept_name`
84
- FROM (
85
- SELECT `concept_id`, `concept_name`, `change_type`,
86
- ROW_NUMBER() OVER (
87
- PARTITION BY `concept_id`
88
- ORDER BY (`change_time` IS NULL) ASC, `change_time` DESC
89
- ) AS `_yjdata_row_number`
90
- FROM `thsetl`.`concept_change_record`
91
- WHERE (`change_time` <= %s) AND (`isvalid` = %s)
92
- ) AS `_yjdata_latest`
93
- WHERE `_yjdata_row_number` = 1 AND (`change_type` <> %s)
94
- ```
95
-
96
- Values are bound in order: `[cutoff, "1", "conceptNegation"]`. PostgreSQL uses
97
- the same query structure with double-quoted identifiers. Expressions retain
98
- normal SQL NULL semantics: for example, `change_type <> 'conceptNegation'`
99
- also excludes NULL `change_type` values.
100
-
101
- ## Tests
102
-
103
- ```sh
104
- python -m unittest discover -s tests -v
105
- ```
106
-
107
- The grouped-selection tests exercise both adapters' validation, quoting,
108
- parameter ordering, and result conversion. Generated queries execute against
109
- SQLite's window engine with DB-API placeholders translated, so tests require
110
- neither optional database drivers nor running servers. This does not replace
111
- integration testing against MySQL and PostgreSQL.
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