From e72c0ebd0e3d4179e8e84417a1ff9ae32d8b68f5 Mon Sep 17 00:00:00 2001 From: Russel Webber <24542073+RusselWebber@users.noreply.github.com> Date: Thu, 30 Jan 2025 14:45:08 +0000 Subject: [PATCH 1/6] Working on benchmarks and docs --- README.md | 60 ++++++++- benchmarks/bench_loadperf.py | 89 +++++++++++++ pyproject.toml | 20 ++- uv.lock | 251 ++++++++++++++++++++++++++++++++++- 4 files changed, 415 insertions(+), 5 deletions(-) create mode 100644 benchmarks/bench_loadperf.py diff --git a/README.md b/README.md index f194d79..11c307d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,61 @@ +![GitHub Latest Tag](https://badgen.net/github/tag/RusselWebber/arrowsqlbcpy) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/RusselWebber/arrowsqlbcpy/ci.yml) ![PyPI Python Versions](https://img.shields.io/pypi/pyversions/arrowsqlbcpy) + # arrowsqlbcpy -A tiny library that uses .Net SqlBulkCopy to enable fast data loading to SQL Server. Apache Arrow is used to serialise data between Python and the native DLL. .Net AOT compilation is used to generate the native DLL. +A tiny library that uses .Net SqlBulkCopy to enable fast data loading to Microsoft SQL Server. Apache Arrow is used to serialise data between Python and the native DLL. .Net Native Library AOT compilation is used to generate the native DLL. + +This library is _much_ faster than any other Python solution, including bcpy, pyodbc and pymssql. + +## Installation + +Binary wheels are available from PyPi and can be installed using your preferred package manager: + +> pip install arrowsqlbcpy + +or + +> uv add arrowsqlbcpy + +## Requirements + +Wheels are available for the latest versions of Windows 64 bit, MacOS ARM 64bit and Ubuntu 64 bit. + +Wheels are available for Python 3.9-3.13. + +## Usage + +Connection strings for .Net are documented [here](https://www.connectionstrings.com/microsoft-data-sqlclient/) + +````python + +import pandas as pd +from arrowsqlbcpy import bulkcopy_from_pandas + +# Create a connection string +cn = r"Server=myServerAddress;Database=myDataBase;Trusted_Connection=True;" +# The table to load into must exist and have the same column names and types as the pandas df +tablename = "test" + +df = pd.DataFrame({"a":[1]*10000, "b":[2]*10000, "c":[3]*10000}) + +bulkcopy_from_pandas(df, cn, tablename) + +``` + +When testing it can be useful to have pandas create the table for you, see tests/test_load.py for an example. + + + +## Benchmarks + +### Windows + +### Ubuntu (WSL2) + + +## Limitations + +bulkcopy_from_pandas() will establish its own database connection to load the data, using existing connections and transactions are not not supported. + +Only basic MacOS testing has been done. +```` diff --git a/benchmarks/bench_loadperf.py b/benchmarks/bench_loadperf.py new file mode 100644 index 0000000..d4436b3 --- /dev/null +++ b/benchmarks/bench_loadperf.py @@ -0,0 +1,89 @@ +import pandas as pd +from arrowsqlbcpy import bulkcopy_from_pandas +from sqlalchemy import create_engine, text +from sqlalchemy.engine import URL +from bcpandas import SqlCreds, to_sql +from functools import partial + +cn = r"Server=PC\SQLEXPRESS;Database=test;Trusted_Connection=True;Encrypt=false;" +tablename = "test" +max_chunksize = None +df = pd.read_parquet(r"C:\Users\russe\Downloads\yellow_tripdata_2024-01.parquet") + +connection_url = URL.create( + "mssql+pyodbc", + host=r"PC\SQLEXPRESS", + database="test", + query={ + "driver": "SQL Server Native Client 11.0", + "Encrypt": "yes", + "TrustServerCertificate": "yes", + }, +) +engine = create_engine(connection_url) +fast_executemany_engine = create_engine(connection_url, echo=False, fast_executemany=True) +creds = SqlCreds.from_engine(engine) + +# Create the table +df.head(1).to_sql(name=tablename, con=engine, index=False, if_exists="replace") + +def default_to_sql(nrows=None): + with engine.begin() as conn: + conn.execute(text(f"TRUNCATE TABLE {tablename}")) + local_df = df.iloc[:nrows] if nrows else df + with engine.begin() as conn: + local_df.to_sql(name=tablename, con=conn, index=False, chunksize=max_chunksize, if_exists="append") + +def fast_executemany__to_sql(nrows=None): + with fast_executemany_engine.begin() as conn: + conn.execute(text(f"TRUNCATE TABLE {tablename}")) + local_df = df.iloc[:nrows] if nrows else df + with engine.begin() as conn: + local_df.to_sql(name=tablename, con=conn, index=False, chunksize=max_chunksize, if_exists="append") + +def arrow_to_sql(nrows=None): + with engine.begin() as conn: + conn.execute(text(f"TRUNCATE TABLE {tablename}")) + local_df = df.iloc[:nrows] if nrows else df + bulkcopy_from_pandas(local_df, cn, tablename) + +def bcpandas_to_sql(nrows=None): + with engine.begin() as conn: + conn.execute(text(f"TRUNCATE TABLE {tablename}")) + local_df = df.iloc[:nrows] if nrows else df + to_sql(local_df, tablename, creds, index=False, if_exists="append") + +default_to_sql_1000 = partial(default_to_sql, 1_000) +fast_executemany__to_sql_1000 = partial(fast_executemany__to_sql, 1_000) +arrow_to_sql_1000 = partial(arrow_to_sql, 1_000) +bcpandas_to_sql_1000 = partial(bcpandas_to_sql, 1_000) +default_to_sql_10000 = partial(default_to_sql, 10_000) +fast_executemany__to_sql_10000 = partial(fast_executemany__to_sql, 10_000) +arrow_to_sql_10000 = partial(arrow_to_sql, 10_000) +bcpandas_to_sql_10000 = partial(bcpandas_to_sql, 10_000) +default_to_sql_100000 = partial(default_to_sql, 100_000) +fast_executemany__to_sql_100000 = partial(fast_executemany__to_sql, 100_000) +arrow_to_sql_100000 = partial(arrow_to_sql, 100_000) +bcpandas_to_sql_100000 = partial(bcpandas_to_sql, 100_000) +default_to_sql_1000000 = partial(default_to_sql, 1_000_000) +fast_executemany__to_sql_1000000 = partial(fast_executemany__to_sql, 1_000_000) +arrow_to_sql_1000000 = partial(arrow_to_sql, 1_000_000) +bcpandas_to_sql_1000000 = partial(bcpandas_to_sql, 1_000_000) + +__benchmarks__ = [ + (default_to_sql_1000, fast_executemany__to_sql_1000, "1e3 rows - fast_executemany=True"), + (default_to_sql_1000, bcpandas_to_sql_1000, "1e3 rows - bcpandas"), + (default_to_sql_1000, arrow_to_sql_1000, "1e3 rows - pyArrow SQLBulkCopy"), + (default_to_sql_10000, fast_executemany__to_sql_10000, "1e4 rows - fast_executemany=True"), + (default_to_sql_10000, bcpandas_to_sql_10000, "1e4 rows - bcpandas"), + (default_to_sql_10000, arrow_to_sql_10000, "1e4 rows - pyArrow SQLBulkCopy"), + (default_to_sql_100000, fast_executemany__to_sql_100000, "1e5 rows - fast_executemany=True"), + (default_to_sql_100000, bcpandas_to_sql_100000, "1e5 rows - bcpandas"), + (default_to_sql_100000, arrow_to_sql_100000, "1e5 rows - pyArrow SQLBulkCopy"), + (default_to_sql_1000000, fast_executemany__to_sql_1000000, "1e6 rows - fast_executemany=True"), + (default_to_sql_1000000, bcpandas_to_sql_1000000, "1e6 rows - bcpandas"), + (default_to_sql_1000000, arrow_to_sql_1000000, "1e6 rows - pyArrow SQLBulkCopy"), + (default_to_sql, fast_executemany__to_sql, "3e6 rows - fast_executemany=True"), + (default_to_sql, bcpandas_to_sql, "3e6 rows - bcpandas"), + (default_to_sql, arrow_to_sql, "3e6 rows - pyArrow SQLBulkCopy") +] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d9aceeb..a2af51e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "arrowsqlbcpy" license = {text = "MIT"} -description = "Fast bcp from pandas to SQL Server using .Net SqlBulkCopy" +description = "A tiny library that uses .Net SqlBulkCopy to enable fast data loading to Microsoft SQL Server. Apache Arrow is used to serialise data between Python and the native DLL. .Net Native Library AOT compilation is used to generate the native DLL." readme = "README.md" keywords = ["bcp", "sql", "pandas"] authors = [ @@ -13,6 +13,22 @@ dependencies = [ "pyarrow>=19.0.0", ] dynamic = ["version"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: C#", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Database", + "Topic :: Database :: Database Engines/Servers" +] + +[project.urls] +Repository = "https://github.com/RusselWebber/arrowsqlbcpy.git" [build-system] requires = ["setuptools>=61", "wheel"] @@ -33,9 +49,11 @@ addopts = [ [dependency-groups] dev = [ + "bcpandas>=2.6.5", "pymssql>=2.3.2", "pyodbc>=5.2.0", "pytest>=8.3.4", + "richbench>=1.0.3", "ruff>=0.9.3", "sqlalchemy>=2.0.37", "wheel>=0.45.1", diff --git a/uv.lock b/uv.lock index 20f4f19..b315803 100644 --- a/uv.lock +++ b/uv.lock @@ -1,15 +1,86 @@ version = 1 requires-python = ">=3.9" resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", "python_full_version == '3.10.*'", "python_full_version < '3.10'", ] +[[package]] +name = "adbc-driver-manager" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/2c/580f0e2024ce0c6330c5e990eaeffd2b206355b5929d19889b3eac008064/adbc_driver_manager-1.4.0.tar.gz", hash = "sha256:fb8437f3e3aad9aa119ccbdfe05b83418ae552ca7e1e3f6d46d53e1f316610b8", size = 107712 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/8b/4d39624e8c35d0a1efed8a9f250e8d70ce5577753e35bee93af821ba46dd/adbc_driver_manager-1.4.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:018622cefcfa5ab60faefe31ca687075dc9fdfc814d39fb34a12afd26523511f", size = 380842 }, + { url = "https://files.pythonhosted.org/packages/e7/a0/2620ea468b4fb878a7398f968be096cd6a3ee248e92b07ee41b507fc2e01/adbc_driver_manager-1.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d38f512cc52e44fdfaea19410e49032cf8599fd2df94134467e2325fc14cd6e5", size = 368018 }, + { url = "https://files.pythonhosted.org/packages/7e/69/b629f8b5f80e40cf8bbcdccff436ba298e588f04f40d173e5db6db801864/adbc_driver_manager-1.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d8dc29ec8997217e45746205f42a0c79fdc44d07b76b97422fb11b12478b4d6", size = 2038383 }, + { url = "https://files.pythonhosted.org/packages/dc/bf/075c581aae412e2588472066a32c66aee79fcbbfc14e119275c2bfa4ef00/adbc_driver_manager-1.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fa6d7dec7b3629bb154606e7ea40e8b82beb6c6950658e39fd32d8febfb94f0", size = 2060218 }, + { url = "https://files.pythonhosted.org/packages/e5/6b/949f034e30e0c0b4942d200c0d8773e2806ec283835eb1f263e3ac14ec26/adbc_driver_manager-1.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:2c3073ff54998f04ef92f43d7637b5e96d2437f13a9e243db32c807031f8819e", size = 536713 }, + { url = "https://files.pythonhosted.org/packages/b9/07/72cfaec3fb1e5090e4495bb310e4ae62d4262ea2330ee7f7395909b3505d/adbc_driver_manager-1.4.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:7bd274386c3decd52212d225cc46f934ce3503a3f9e0e823e4f8a40162d89b2b", size = 381818 }, + { url = "https://files.pythonhosted.org/packages/20/80/efb076dd9148f903a4e96f969dd7a0cdafeeabba8e14c1db9bf21452ce31/adbc_driver_manager-1.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:90d1055f2557d703fa2e7073d7b26cc6394159ff4b1422d2dae05d08b774f687", size = 368704 }, + { url = "https://files.pythonhosted.org/packages/0f/29/ed9525e46d474230a0fb310ab077a681b49c45c6e4e5a305e0c704702256/adbc_driver_manager-1.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db899b18caeb9e26b99c0baf17e239f35274d6c3dc3aa0e3afc4d278ef3c6747", size = 2150204 }, + { url = "https://files.pythonhosted.org/packages/a5/32/c00c7b5dd4c187003f0f6799090d17db42effc3396b5a6971228e0d1cbb4/adbc_driver_manager-1.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1a7079c7f254c7010afe08872c4bfab6e716a507fc343c32204e8ce6bfd898", size = 2164967 }, + { url = "https://files.pythonhosted.org/packages/37/30/3b62800f5f7aad8c51e2e71fc8e9a87dadb007588289fddab09d180ea1ae/adbc_driver_manager-1.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:27a8943ba838a4038b4ec6466e11eafe336ca5d382adb7c5d2cc9c00dd44bd10", size = 538168 }, + { url = "https://files.pythonhosted.org/packages/59/30/e76d5bdb044b4126b4deed32ff5cf02b62d9e7eba4eec5a06c6005a3952f/adbc_driver_manager-1.4.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:3770180aa2cccc6c18ffd189d623ebbf35dccad75895f0fd4275b73d39297849", size = 381431 }, + { url = "https://files.pythonhosted.org/packages/c8/25/a96b04c0162253ff9e014b774789cc76e84e536f9ef874c9d2af240bfa42/adbc_driver_manager-1.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ba26a0510d1e7596c827d1ef58185d32fb4f97837e463608801ec3b4850ce74", size = 365687 }, + { url = "https://files.pythonhosted.org/packages/1e/8d/caae84fceed7e2cff414ce9a17f62eee0ceca98058bb8b1fbde1a1941904/adbc_driver_manager-1.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50eedc6b173a80a0a8f46ef6352761a2c53063ac94327b1f45603db33176010d", size = 2129076 }, + { url = "https://files.pythonhosted.org/packages/fb/8b/7d4ce1622b2930205261c5b7dae7ded7f3328033fdfe02f213f2bb41720f/adbc_driver_manager-1.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:168a07ce237902dd9aa0e079d7e8c131ad6f61fb20f3b43020597b2a3c29d3ea", size = 2161148 }, + { url = "https://files.pythonhosted.org/packages/59/ab/991d5dc4d8b65adab2990f949524ce5ca504aa84727bc501fa6ba919288f/adbc_driver_manager-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c165069fe3bcffc25e593cb933958b4a0563aff317cb6a37fc390c8dd0ed26f", size = 535674 }, + { url = "https://files.pythonhosted.org/packages/80/97/e33d558177e8dcbdaec2191bc37970e5068e46095a21dc157aaa65530e58/adbc_driver_manager-1.4.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:acfe7ca9c227a0835abc52289601337bde7a96c20befc443c9ba4eb78d673214", size = 379612 }, + { url = "https://files.pythonhosted.org/packages/1e/4f/11aacce2421902b9bed07970d5f4565c5948f58788392962ffeceadbac21/adbc_driver_manager-1.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b5fbf822e90fc6df5321067a25564cae11b404d4c9ba56fe4471c73c5b54e38f", size = 363412 }, + { url = "https://files.pythonhosted.org/packages/0d/bd/68672ab658dbcb154500cb668e2fe630861b3ac3348c5cdb6bf501ae10ab/adbc_driver_manager-1.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e1d255116003514088990cc5af5995cc7f5d2ebea96b00e5b2a1f4d922d7137", size = 2123358 }, + { url = "https://files.pythonhosted.org/packages/f7/4a/5a966873541d580bf27711355ed9fd40cd46bea702eb092c6825306957a6/adbc_driver_manager-1.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:682364a252de1029fa12a2f7526d4cb9e8e03e009d8d0f64bc3530a7539a74f6", size = 2156251 }, + { url = "https://files.pythonhosted.org/packages/ca/4b/19d32beccfcb647451db25cc2e40dbeb6b763bf274cdc85d22e68511baa4/adbc_driver_manager-1.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:f01441fb8cc7859037ae0c39692bffa26f2fa0da68664589ced5b3794540f402", size = 533846 }, + { url = "https://files.pythonhosted.org/packages/e4/82/eac48a29eabc0a122537a3473b9f983c880caaa38c1cf9cdba977b909aa9/adbc_driver_manager-1.4.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:36a1019da980b4d0250f6139a0b4da019e362dd1dbc8cc1f4e5572590e4a765d", size = 382181 }, + { url = "https://files.pythonhosted.org/packages/68/b0/5ffd86cd152ee98b9d3df2b06bb717fc95d763e27f47c1cd21a1a8cd151d/adbc_driver_manager-1.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b4e59fcb9ba1e0c61f904d52a378ded3b5841aa6f6679fe01e2e10211dfcd0b9", size = 369121 }, + { url = "https://files.pythonhosted.org/packages/2f/38/327490f80e3b29e6af5068b52e23935a737e19dca8bba36104e9ff790d40/adbc_driver_manager-1.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88456823258e2bb43a5e43c18c462033da0877025784ee72ec6db7105709d510", size = 2044622 }, + { url = "https://files.pythonhosted.org/packages/78/c4/239c3f4a68ef1c056a21c00c60be9c98985adc6337af02d83546c56d3fce/adbc_driver_manager-1.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c113a4f3bed057217b046a1c22a7cf0daed63533faa1107da6b996f56b162e56", size = 2059145 }, + { url = "https://files.pythonhosted.org/packages/14/3f/36cd720c1bb1036405105317ed0fb32e4488048ec5be07bf502d6d5f607f/adbc_driver_manager-1.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:39a1a84783e2290c34f348382fe257500c0ea0b1c3232199cfc3dc15397af236", size = 538314 }, +] + +[[package]] +name = "adbc-driver-postgresql" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "adbc-driver-manager", marker = "python_full_version >= '3.12'" }, + { name = "importlib-resources", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/8b/5d088a3c4d739a3a62df081a819117df924318b5044ab3214d72723591ad/adbc_driver_postgresql-1.4.0.tar.gz", hash = "sha256:0bc9c34911b8d1a2e333ee5ef3d284a6517a8f20d5509b2c183cb4cf8337efc7", size = 18402 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/69/165a7155a9514aa472beae1e16fc93c51a363c7deed0db7d2d470c28788d/adbc_driver_postgresql-1.4.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:ca62087f1496192c3131a30fc3e4d2710fa3b31b1982bf8374b6a05fdc446513", size = 2683068 }, + { url = "https://files.pythonhosted.org/packages/7c/37/d991e047b931a3f005156a43c8be92ffbfd234589d5969b38adee03faa80/adbc_driver_postgresql-1.4.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c75827a5cfdc8ea429c21292ac6bd598a64a42af53797c7ace3ad2b3044ddc71", size = 2996305 }, + { url = "https://files.pythonhosted.org/packages/2d/5e/dc25c82cf2055e500b2f8e47093cf6037fd636866e0e6c54ccadccda1e6a/adbc_driver_postgresql-1.4.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ce4e30ced25802acd6d0baad70e7b9c7394869043c1349206218d51d27613c1", size = 3190063 }, + { url = "https://files.pythonhosted.org/packages/42/bd/910e7b8e0d6e91f94348fe23a2f8d3dfc4e86a0b933b26d41a4298f14772/adbc_driver_postgresql-1.4.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d04b2ea0353bacca814fb15c5da0d407888d27e86b4f3f58d66ebe71a2e8d3da", size = 2846350 }, + { url = "https://files.pythonhosted.org/packages/7d/fc/19fb644ab228040a01971e21bb42c98c48a95d3701e68dd38fa1bfb0bc61/adbc_driver_postgresql-1.4.0-py3-none-win_amd64.whl", hash = "sha256:10ae2584e47928e15da8ca541bbf594de13d6c1afc8c1adad6af3f832cf0a5fc", size = 2656378 }, +] + +[[package]] +name = "adbc-driver-sqlite" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "adbc-driver-manager", marker = "python_full_version >= '3.12'" }, + { name = "importlib-resources", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/13/0a5ab8ecebbe13b1abd3e4bdd966250685d63a9c066bd56f59e3a9488bea/adbc_driver_sqlite-1.4.0.tar.gz", hash = "sha256:957171d87e28a917c6b22a558ef226beb7490740813609e77bf37098c60d53bd", size = 17007 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/76/11efcc31e2851a60246995abe76419c1b81ad0c7cada35635c89fecee734/adbc_driver_sqlite-1.4.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:9754f956ffb9749a19365bd6749f0f6e8ec82387d85ea66c08455e443095878a", size = 1040870 }, + { url = "https://files.pythonhosted.org/packages/8a/71/b726710e0c111324d4331c2ce272f65eb71642e37118e52892d148d38a10/adbc_driver_sqlite-1.4.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7f602528ee8c2ef78f8e7ad409d37bbbc604ca404ad93e5b52685d32c73b6e00", size = 1011240 }, + { url = "https://files.pythonhosted.org/packages/d1/9c/3e421bde3c6501c0298f13074055935a64a16d0dae4b83e01e70a4bfbd0e/adbc_driver_sqlite-1.4.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30f8d658343923a10933ca9657f6373e7fb5e6e05891ec70bc27a719160f0484", size = 955018 }, + { url = "https://files.pythonhosted.org/packages/d7/96/b387c1e47a263e315861eed4bc1680ded534b4e220c365d5804364852add/adbc_driver_sqlite-1.4.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f111e982a0816c1f6471c32b87fbfdc21431d46004be301484047acc62d5b07e", size = 976394 }, + { url = "https://files.pythonhosted.org/packages/09/f2/0d250000bb12d0c37a010d26260893624b111fdfe3bd407e5fa369aea90e/adbc_driver_sqlite-1.4.0-py3-none-win_amd64.whl", hash = "sha256:0a22cd5c442f31a87727ba62fb6137f08df54774a25e355e7da06617ecf56b6a", size = 863483 }, +] + [[package]] name = "arrowsqlbcpy" -version = "0.0.1.dev0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "pandas" }, @@ -18,9 +89,12 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "bcpandas", version = "2.6.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "bcpandas", version = "2.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "pymssql" }, { name = "pyodbc" }, { name = "pytest" }, + { name = "richbench" }, { name = "ruff" }, { name = "sqlalchemy" }, { name = "wheel" }, @@ -34,14 +108,53 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "bcpandas", specifier = ">=2.6.5" }, { name = "pymssql", specifier = ">=2.3.2" }, { name = "pyodbc", specifier = ">=5.2.0" }, { name = "pytest", specifier = ">=8.3.4" }, + { name = "richbench", specifier = ">=1.0.3" }, { name = "ruff", specifier = ">=0.9.3" }, { name = "sqlalchemy", specifier = ">=2.0.37" }, { name = "wheel", specifier = ">=0.45.1" }, ] +[[package]] +name = "bcpandas" +version = "2.6.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] +dependencies = [ + { name = "pandas", marker = "python_full_version < '3.12'" }, + { name = "pyodbc", marker = "python_full_version < '3.12'" }, + { name = "sqlalchemy", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/ac/cb3e90271a186d49da5039f41bf95f2c29ae1dbced751c52ad15d1b5d139/bcpandas-2.6.5.tar.gz", hash = "sha256:1f68df236e966852d3c91825626e81f6f3325d213b3ff3bbe5e28571525fa990", size = 25093 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/a0/e5ffc8465474357aa21870882f812324db7a56b7d2f4317988da3fa132c7/bcpandas-2.6.5-py3-none-any.whl", hash = "sha256:51d23e7893c8d6716df302cf833e42e0db24010a8c4542d99891bbf72312285d", size = 20444 }, +] + +[[package]] +name = "bcpandas" +version = "2.7.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", +] +dependencies = [ + { name = "pandas", extra = ["sql-other"], marker = "python_full_version >= '3.12'" }, + { name = "pyodbc", marker = "python_full_version >= '3.12'" }, + { name = "sqlalchemy", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/61/69888dff70436049ab6127392dea7b3e9cae7e86c1cb2ff5f382afe6accc/bcpandas-2.7.2.tar.gz", hash = "sha256:f3d47e737f0c99643d9a16a538c7d2fd7097bc5867483f7647dca9c8332b85b5", size = 33436 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d4/c6bb3b6cb97b3ebf5922c7830158d2628797e43e444324c0db05747165d6/bcpandas-2.7.2-py3-none-any.whl", hash = "sha256:bc198074b925274944d2b20088a8a6113b51110eb79fe97000a14d0e7573ae98", size = 19860 }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -121,6 +234,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/02/e7d0aef2354a38709b764df50b2b83608f0621493e47f47694eb80922822/greenlet-3.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:3319aa75e0e0639bc15ff54ca327e8dc7a6fe404003496e3c6925cd3142e0e22", size = 298306 }, ] +[[package]] +name = "importlib-resources" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461 }, +] + [[package]] name = "iniconfig" version = "2.0.0" @@ -130,6 +252,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, ] +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + [[package]] name = "numpy" version = "2.0.2" @@ -190,7 +333,8 @@ name = "numpy" version = "2.2.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", "python_full_version == '3.10.*'", ] @@ -317,6 +461,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/49/5c30646e96c684570925b772eac4eb0a8cb0ca590fa978f56c5d3ae73ea1/pandas-2.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:4850ba03528b6dd51d6c5d273c46f183f39a9baf3f0143e566b89450965b105e", size = 11618011 }, ] +[package.optional-dependencies] +sql-other = [ + { name = "adbc-driver-postgresql", marker = "python_full_version >= '3.12'" }, + { name = "adbc-driver-sqlite", marker = "python_full_version >= '3.12'" }, + { name = "sqlalchemy", marker = "python_full_version >= '3.12'" }, +] + [[package]] name = "pluggy" version = "1.5.0" @@ -375,6 +526,73 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/28/c51c9af2703b5a592d1b66546611b24de8ca01e04c3f5da769c3318bca6c/pyarrow-19.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:597360ffc71fc8cceea1aec1fb60cb510571a744fffc87db33d551d5de919bec", size = 25464978 }, ] +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "pyinstrument" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/6e/85c2722e40cab4fd9df6bbe68a0d032e237cf8cfada71e5f067e4e433214/pyinstrument-5.0.1.tar.gz", hash = "sha256:f4fd0754d02959c113a4b1ebed02f4627b6e2c138719ddf43244fd95f201c8c9", size = 263162 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/35/06f943dc6bc147e0f39db714b14a67fa2dcff4930392658b529e8f523530/pyinstrument-5.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a14d3a90c432f1ce1be91716fa76b75dc74ed03100282878d2a4d30c7c75c980", size = 129015 }, + { url = "https://files.pythonhosted.org/packages/ff/a8/d91857423b9c0f9604db9974b782753049e9f6f86f3500fb76306c4b06bf/pyinstrument-5.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6afe94a27a9016b365b9dd3a5f03732a3cd29d8bcb178113b09e73d36cf51196", size = 121591 }, + { url = "https://files.pythonhosted.org/packages/68/c4/6ad462fc766f578973402aca949ac7783a7c40c2e750b9a996bd6640ccae/pyinstrument-5.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3f16a0bde13a4ac1b8fdbcaf49626926e523028bd68804caa186ba9e9c51d09", size = 145275 }, + { url = "https://files.pythonhosted.org/packages/d3/20/9c9732ac3e0be811df6893f7230bc0b3b5b2c2e95c0bed415de51a2324dc/pyinstrument-5.0.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ceee4fa6c24c5a1c346ee641b50f63438cf76bf25d2e86ee6fbfe5d505e00e6", size = 144076 }, + { url = "https://files.pythonhosted.org/packages/f3/40/f0d5920cea0543012367b338cd8ce6b1cbc9e4dd98e31829946d35f650be/pyinstrument-5.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:835ecac9061ce8926321276b47b7d17a6ae19a932d33c5ef7be632a83a07f78a", size = 145396 }, + { url = "https://files.pythonhosted.org/packages/de/c7/a365da27070773f8fb6e2f6e305b0962fa60a6acbb802772d5e348b9a599/pyinstrument-5.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c0e26a6fc51f259882b621a13ec2736a4788a57e304a102aee1bf0401eb29ce2", size = 144925 }, + { url = "https://files.pythonhosted.org/packages/75/00/a56bc74cb4468b413e6849067d95b7c3ba59386ed703045437d8f040a7d5/pyinstrument-5.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81b7192c7dc956923829355a85ac361f2409093a3f998e8a0294ffd447863526", size = 144397 }, + { url = "https://files.pythonhosted.org/packages/db/08/eaac32dfed78a8b0cf4398da4b9bf36c370ff42d963f666f52293fec9cdc/pyinstrument-5.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d18e37baaaae969f3cf5b3187db386de7f458a8393f6825564ebb6e51714363a", size = 144795 }, + { url = "https://files.pythonhosted.org/packages/47/3e/fd73018f941e658a2b1b736c8e8e181df3735f117331dab4c33f43fbe1d7/pyinstrument-5.0.1-cp310-cp310-win32.whl", hash = "sha256:cbfdc71be2dd8e5a8a349df0430e4908897ced448a2f2c50c1cac493cd2565b5", size = 122957 }, + { url = "https://files.pythonhosted.org/packages/4c/76/5c5b2e7b381a470ced1d899ee55c532e68269e72ec1b0b9e9c3b561acc49/pyinstrument-5.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:442c763c8311557062a7ad20f9edd77600182cb14cd9fcb207cdf947d42038bb", size = 123830 }, + { url = "https://files.pythonhosted.org/packages/d8/6e/dab9eef973f8a573eea492f2ad6ba46a5fc3ce6ae947947a97f7b40ddf6e/pyinstrument-5.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a5f0a468382198b84991e83beff7c43e9315f974379b17abcc285caff154bdfc", size = 128765 }, + { url = "https://files.pythonhosted.org/packages/14/2d/c729e0bc525d070a1916b8a84c0b6088e85ea8d79f507f1c7c1a66db6cda/pyinstrument-5.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dabda1485011aa2bfa6cb293020f2e35163ccc3b2746c1e72ff0ea5e62dfe730", size = 121472 }, + { url = "https://files.pythonhosted.org/packages/e3/54/dc9fbd755337b66fb0a8309bb3451379ecee1236ff17ec44323b54f61ee7/pyinstrument-5.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f54285f0924d443dd27f0510693a76ecafd6d38573be2254b3c86314db42efe", size = 143599 }, + { url = "https://files.pythonhosted.org/packages/63/f3/26394bd74f5fe632b0a7670f008f675df397fb38d5d8fb363f5243ce8dd9/pyinstrument-5.0.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7387acabf1eb74b7a0deead0d5ad3b1a41c2c7b2d7c9b5507047f04700d0b446", size = 142529 }, + { url = "https://files.pythonhosted.org/packages/73/9a/7751e9070a6f7a4ae56de93e3e8991cf321c15f9878b2a1c390ea1839e3b/pyinstrument-5.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5ee80ac5e7821c28458b19ca61b082e1f71f1171e2c5da700e07e21c114fd31", size = 143670 }, + { url = "https://files.pythonhosted.org/packages/fa/3c/9421fa66fdf60d80994b2d69ae4d22a89a98b9993fb7d09552374902f340/pyinstrument-5.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d29093f7fd419aa26c0ef5a81dfed80cbe48799d9ab977f343570a6864ce76e2", size = 143618 }, + { url = "https://files.pythonhosted.org/packages/68/62/17a973a9dee2ce1e25d9b3289b0d606fa4e512a6dd4df64e3aed87bcb28a/pyinstrument-5.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:000de38068c10769ce9268955191df2738b065e606b5a3453077e31c0db96259", size = 143142 }, + { url = "https://files.pythonhosted.org/packages/cc/cb/9812a0ee561c158ad91d1eaa90291772061708676a8cf81e81934cbe3bfd/pyinstrument-5.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:219ed803f5e3887a9f345ec73c9e2b1f76e993202bc8f9c46a681cda2b7040f6", size = 143383 }, + { url = "https://files.pythonhosted.org/packages/93/37/8d8ea4442f2c067e0c15c745e4bf5e04eae7a6a1f48ad909a96a9fee32a3/pyinstrument-5.0.1-cp311-cp311-win32.whl", hash = "sha256:fe85109415bc63e2cc22144e6c6202b99a8087dc54330abf6d1067c775c6eb54", size = 122929 }, + { url = "https://files.pythonhosted.org/packages/d7/e9/1565ac257a7b6c9d439823848d065196fb13082d952212eaf28467737615/pyinstrument-5.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:29ff672575fc44ca775c1bd6d5871323d6e8e3b5ad49791107b750be682e5865", size = 123737 }, + { url = "https://files.pythonhosted.org/packages/e1/09/696e29364503393c5bd0471f1c396d41820167b3f496bf8b128dc981f30d/pyinstrument-5.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cfd7b7dc56501a1f30aa059cc2f1746ece6258a841d2e4609882581f9c17f824", size = 128903 }, + { url = "https://files.pythonhosted.org/packages/b5/dd/36d1641414eb0ab3fb50815de8d927b74924a9bfb1e409c53e9aad4a16de/pyinstrument-5.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fe1f33178a2b0ddb3c6d2321406228bdad41286774e65314d511dcf4a71b83e4", size = 121440 }, + { url = "https://files.pythonhosted.org/packages/9e/3f/05196fb514735aceef9a9439f56bcaa5ccb8b440685aa4f13fdb9e925182/pyinstrument-5.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0519d02dee55a87afcf6d787f8d8f5a16d2b89f7ba9533064a986a2d31f27340", size = 144783 }, + { url = "https://files.pythonhosted.org/packages/73/4b/1b041b974e7e465ca311e712beb8be0bc9cf769bcfc6660b1b2ba630c27c/pyinstrument-5.0.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f59ed9ac9466ff9b30eb7285160fa794aa3f8ce2bcf58a94142f945882d28ab", size = 143717 }, + { url = "https://files.pythonhosted.org/packages/4a/dc/3fa73e2dde1588b6281e494a14c183a27e1a67db7401fddf9c528fb8e1a9/pyinstrument-5.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbf3114d332e499ba35ca4aedc1ef95bc6fb15c8d819729b5c0aeb35c8b64dd2", size = 145082 }, + { url = "https://files.pythonhosted.org/packages/91/24/b86d4273cc524a4f334a610a1c4b157146c808d8935e85d44dff3a6b75ee/pyinstrument-5.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:20f8054e85dd710f5a8c4d6b738867366ceef89671db09c87690ba1b5c66bd67", size = 144737 }, + { url = "https://files.pythonhosted.org/packages/3c/39/6025a71082122bfbfee4eac6649635e4c688954bdf306bcd3629457c49b2/pyinstrument-5.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:63e8d75ffa50c3cf6d980844efce0334659e934dcc3832bad08c23c171c545ff", size = 144488 }, + { url = "https://files.pythonhosted.org/packages/da/ce/679b0e9a278004defc93c277c3f81b456389dd530f89e28a45bd9dae203e/pyinstrument-5.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a3ca9c8540051513dd633de9d7eac9fee2eda50b78b6eedeaa7e5a7be66026b5", size = 144895 }, + { url = "https://files.pythonhosted.org/packages/58/d8/cf80bb278e2a071325e4fb244127eb68dce9d0520d20c1fda75414f119ee/pyinstrument-5.0.1-cp312-cp312-win32.whl", hash = "sha256:b549d910b846757ffbf74d94528d1a694a3848a6cfc6a6cab2ce697ee71e4548", size = 123027 }, + { url = "https://files.pythonhosted.org/packages/39/49/9251fe641d242d4c0dc49178b064f22da1c542d80e4040561428a9f8dd1c/pyinstrument-5.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:86f20b680223697a8ac5c061fb40a63d3ee519c7dfb1097627bd4480711216d9", size = 123818 }, + { url = "https://files.pythonhosted.org/packages/0f/ae/f8f84ecd0dc2c4f0d84920cb4ffdbea52a66e4b4abc2110f18879b57f538/pyinstrument-5.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f5065639dfedc3b8e537161f9aaa8c550c8717c935a962e9bf1e843bf0e8791f", size = 128900 }, + { url = "https://files.pythonhosted.org/packages/23/2f/b742c46d86d4c1f74ec0819f091bbc2fad0bab786584a18d89d9178802f1/pyinstrument-5.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b5d20802b0c2bd1ddb95b2e96ebd3e9757dbab1e935792c2629166f1eb267bb2", size = 121445 }, + { url = "https://files.pythonhosted.org/packages/d9/e0/297dc8454ed437aec0fbdc3cc1a6a5fdf6701935b91dd31caf38c5e3ff92/pyinstrument-5.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e6f5655d580429e7992c37757cc5f6e74ca81b0f2768b833d9711631a8cb2f7", size = 144904 }, + { url = "https://files.pythonhosted.org/packages/8b/df/e4faff09fdbad7e685ceb0f96066d434fc8350382acf8df47577653f702b/pyinstrument-5.0.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4c8c9ad93f62f0bf2ddc7fb6fce3a91c008d422873824e01c5e5e83467fd1fb", size = 143801 }, + { url = "https://files.pythonhosted.org/packages/b1/63/ed2955d980bbebf17155119e2687ac15e170b6221c4bb5f5c37f41323fe5/pyinstrument-5.0.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db15d1854b360182d242da8de89761a0ffb885eea61cb8652e40b5b9a4ef44bc", size = 145204 }, + { url = "https://files.pythonhosted.org/packages/c4/18/31b8dcdade9767afc7a36a313d8cf9c5690b662e9755fe7bd0523125e06f/pyinstrument-5.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c803f7b880394b7bba5939ff8a59d6962589e9a0140fc33c3a6a345c58846106", size = 144881 }, + { url = "https://files.pythonhosted.org/packages/1f/14/cd19894eb03dd28093f564e8bcf7ae4edc8e315ce962c8155cf795fc0784/pyinstrument-5.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:84e37ffabcf26fe820d354a1f7e9fc26949f953addab89b590c5000b3ffa60d0", size = 144643 }, + { url = "https://files.pythonhosted.org/packages/80/54/3dd08f5a869d3b654ff7e4e4c9d2b34f8de73fb0f2f792fac5024a312e0f/pyinstrument-5.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a0d23d3763ec95da0beb390c2f7df7cbe36ea62b6a4d5b89c4eaab81c1c649cf", size = 145070 }, + { url = "https://files.pythonhosted.org/packages/5d/dc/ac8e798235a1dbccefc1b204a16709cef36f02c07587763ba8eb510fc8bc/pyinstrument-5.0.1-cp313-cp313-win32.whl", hash = "sha256:967f84bd82f14425543a983956ff9cfcf1e3762755ffcec8cd835c6be22a7a0a", size = 123030 }, + { url = "https://files.pythonhosted.org/packages/52/59/adcb3e85c9105c59382723a67f682012aa7f49027e270e721f2d59f63fcf/pyinstrument-5.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:70b16b5915534d8df40dcf04a7cc78d3290464c06fa358a4bc324280af4c74e0", size = 123825 }, + { url = "https://files.pythonhosted.org/packages/02/cb/76e92f4069c8e14ed1a154a982c4c08ad8f70ae5e21e9f9a5b8f9ef28f4b/pyinstrument-5.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9bdded62e0a6878a4a061d6cfdd9ec92a1ec1002776688a90f0b5329938a087b", size = 129008 }, + { url = "https://files.pythonhosted.org/packages/ce/d2/b296472da1e25883f919857b1e0394d1bb3829da76ffb56ab58e5ab54eb1/pyinstrument-5.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de3a7b81236f893fb43aa428db9919a1fbc8ccb47c2428ade1fc2a6b96e007ec", size = 121586 }, + { url = "https://files.pythonhosted.org/packages/b3/94/75aad28b763b36259deb287a2d4d5567fb5b7823f200e984ace3b0f9efc8/pyinstrument-5.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed712b88fe6f0dbd4a1966d4254e546545e512dc3b69329c74aded0c7e7baff2", size = 144901 }, + { url = "https://files.pythonhosted.org/packages/ee/40/0efcef4354ab1c9343940d498d8192797fb71afc7e785c753746740f880c/pyinstrument-5.0.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:823d2022c47b8d635f0d8ec6dfd36eb3d50a77abfcabc32aa6d3cdea8eea3fe9", size = 143749 }, + { url = "https://files.pythonhosted.org/packages/ab/34/879361b76fc119f4a62cc1628adaa1a524e40b5186d0c0a9da4a23a17123/pyinstrument-5.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47959cd63cfc0559639199a4a88c871790cd7f0a0f9043057e7408048c035319", size = 145011 }, + { url = "https://files.pythonhosted.org/packages/a9/b9/eb9bf583648225e5c3980b577582b0d71ad336fdc9ff8275cf86651ee1a3/pyinstrument-5.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0e0197702dab98ef7da02a9e1def0b9b04659ac09a67266791b096837d0d3f68", size = 144624 }, + { url = "https://files.pythonhosted.org/packages/55/fe/753581a17ac4a9da29923790f67407a279498e617dac8bc967e0ab5eb532/pyinstrument-5.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:248dc2d016fe935ae7365cd0f83f9d32a7285593f23b703b363c2db9f126983f", size = 144054 }, + { url = "https://files.pythonhosted.org/packages/b0/5b/c096f23b9cac850f52025c9b1b9ac7ca41197dc1498bf2f416c3c5557025/pyinstrument-5.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8f3af11d4219360b89307581ea204fde476c6f5ab91afc932c34655f0974ed6f", size = 144500 }, + { url = "https://files.pythonhosted.org/packages/ff/94/733553e1fc43a8a2e6f39ac84e9861f937911f68dde171b1d3a48439c0ce/pyinstrument-5.0.1-cp39-cp39-win32.whl", hash = "sha256:8f1b7d6d4b9d1ed1b9e222352421a5b080a87b9e6b7cd654b9ba94c5c8266286", size = 122966 }, + { url = "https://files.pythonhosted.org/packages/53/0a/32c7f168f45cf6b7e4c7dfa792a509a82ea66d339352366915da5d8a2b22/pyinstrument-5.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:a876d6f6d6ad7840be62d2eeb8af868d3bf9ab0b023e082a79b22909bce7c755", size = 123842 }, +] + [[package]] name = "pymssql" version = "2.3.2" @@ -516,6 +734,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725", size = 508002 }, ] +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, +] + +[[package]] +name = "richbench" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyinstrument" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/5b/d400ecddd716348ff7eac2a40df67cde1c9b60b1f7083577605db4bd4b31/richbench-1.0.3.tar.gz", hash = "sha256:744afa3e78cbd919721042c11f7b7f9d2f546cebb3333d40290c4a0d88791701", size = 1071038 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/a2/725f87821d93f82cba818037fba0b86b14c721d75d4e9935b441ced8471f/richbench-1.0.3-py3-none-any.whl", hash = "sha256:f52651cc0e0069a1355c5ed8cda214cb3f8961e7aaa431e440071d30f62e3e55", size = 5227 }, +] + [[package]] name = "ruff" version = "0.9.3" From e0d3ca52f0d5a36866463de972863f24de93566a Mon Sep 17 00:00:00 2001 From: Russel Webber <24542073+RusselWebber@users.noreply.github.com> Date: Thu, 30 Jan 2025 15:56:10 +0000 Subject: [PATCH 2/6] Relabel benchmark strategies --- README.md | 45 +++++++++++++++++++++++++++++------- benchmarks/bench_loadperf.py | 10 ++++---- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 11c307d..cb59a30 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A tiny library that uses .Net SqlBulkCopy to enable fast data loading to Microsoft SQL Server. Apache Arrow is used to serialise data between Python and the native DLL. .Net Native Library AOT compilation is used to generate the native DLL. -This library is _much_ faster than any other Python solution, including bcpy, pyodbc and pymssql. +This library is _much_ faster than any other Python solution, including bcpandas, pyodbc and pymssql. See the benchmark results below. ## Installation @@ -22,11 +22,26 @@ Wheels are available for the latest versions of Windows 64 bit, MacOS ARM 64bit Wheels are available for Python 3.9-3.13. +### Linux support + +The Ubuntu wheels _may_ work on other Linux distros. Building C# native libaries and then packaging appropriately for multiple Linux distros is not straightforward. The simplest solution for most Linux distros is to simply pull the source from Github and build locally. These are the high-level steps: + +1. Install .net + https://learn.microsoft.com/en-us/dotnet/core/install/linux +2. Clone the source + > git clone https://github.com/RusselWebber/arrowsqlbcpy +3. Install uv + https://docs.astral.sh/uv/getting-started/installation/ +4. Build the wheel locally + > uv build --wheel +5. Install the wheel + > pip install dist/wheel_file.whl + ## Usage Connection strings for .Net are documented [here](https://www.connectionstrings.com/microsoft-data-sqlclient/) -````python +```python import pandas as pd from arrowsqlbcpy import bulkcopy_from_pandas @@ -42,20 +57,34 @@ bulkcopy_from_pandas(df, cn, tablename) ``` -When testing it can be useful to have pandas create the table for you, see tests/test_load.py for an example. +When testing it can be useful to have pandas create the table for you, see [tests/test_load.py](https://github.com/RusselWebber/arrowsqlbcpy/blob/main/tests/test_load.py) for an example. +## Benchmarks +The benchmarks were run using the [richbench](https://github.com/tonybaloney/rich-bench) package. Tests were run repeatedly to get stable benchmarks. -## Benchmarks +> richbench ./benchmarks + +The benchmarks load a 3m row parquet file of New York taxi data. Times are recorded for loading 1000 rows, 10 000 rows, 100 000 rows, 1 000 000 rows and finally all 3 000 000 rows. + +The benchmarks have a baseline of using pandas `to_sql()` and SQLAlchemy with pyodbc and pymssql. This is a common solution for loading pandas dataframes into SQL Server. + +The benchmarks then show the time taken to load using various alternative strategies: + +| Label | Description | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| fast_executemany=True | Use pandas `to_sql()`, SQLAlchemy, pyodbc, pymssql with the fast_executemany=True option as discussed [here](https://stackoverflow.com/questions/48006551/speeding-up-pandas-dataframe-to-sql-with-fast-executemany-of-pyodbc) | +| bcpandas | Use the [bcpandas](https://github.com/yehoshuadimarsky/bcpandas) package to load the dataframes. The package writes temp files and spawns bcp processes to load them | +| arrowsqlbcp | This package using .Net SqlBulkCopy | -### Windows +The richbench tables show the min, max and mean time in seconds for the baseline in the left three columns; then the min, max, mean time in seconds for the alternative strategy. -### Ubuntu (WSL2) +### Windows 11 (local db) +### Ubuntu (WSL2) (local db) ## Limitations -bulkcopy_from_pandas() will establish its own database connection to load the data, using existing connections and transactions are not not supported. +`bulkcopy_from_pandas()` will establish its own database connection to load the data, reusing existing connections and transactions are not supported. Only basic MacOS testing has been done. -```` diff --git a/benchmarks/bench_loadperf.py b/benchmarks/bench_loadperf.py index d4436b3..5f6f351 100644 --- a/benchmarks/bench_loadperf.py +++ b/benchmarks/bench_loadperf.py @@ -73,17 +73,17 @@ def bcpandas_to_sql(nrows=None): __benchmarks__ = [ (default_to_sql_1000, fast_executemany__to_sql_1000, "1e3 rows - fast_executemany=True"), (default_to_sql_1000, bcpandas_to_sql_1000, "1e3 rows - bcpandas"), - (default_to_sql_1000, arrow_to_sql_1000, "1e3 rows - pyArrow SQLBulkCopy"), + (default_to_sql_1000, arrow_to_sql_1000, "1e3 rows - arrowsqlbcp"), (default_to_sql_10000, fast_executemany__to_sql_10000, "1e4 rows - fast_executemany=True"), (default_to_sql_10000, bcpandas_to_sql_10000, "1e4 rows - bcpandas"), - (default_to_sql_10000, arrow_to_sql_10000, "1e4 rows - pyArrow SQLBulkCopy"), + (default_to_sql_10000, arrow_to_sql_10000, "1e4 rows - arrowsqlbcp"), (default_to_sql_100000, fast_executemany__to_sql_100000, "1e5 rows - fast_executemany=True"), (default_to_sql_100000, bcpandas_to_sql_100000, "1e5 rows - bcpandas"), - (default_to_sql_100000, arrow_to_sql_100000, "1e5 rows - pyArrow SQLBulkCopy"), + (default_to_sql_100000, arrow_to_sql_100000, "1e5 rows - arrowsqlbcp"), (default_to_sql_1000000, fast_executemany__to_sql_1000000, "1e6 rows - fast_executemany=True"), (default_to_sql_1000000, bcpandas_to_sql_1000000, "1e6 rows - bcpandas"), - (default_to_sql_1000000, arrow_to_sql_1000000, "1e6 rows - pyArrow SQLBulkCopy"), + (default_to_sql_1000000, arrow_to_sql_1000000, "1e6 rows - arrowsqlbcp"), (default_to_sql, fast_executemany__to_sql, "3e6 rows - fast_executemany=True"), (default_to_sql, bcpandas_to_sql, "3e6 rows - bcpandas"), - (default_to_sql, arrow_to_sql, "3e6 rows - pyArrow SQLBulkCopy") + (default_to_sql, arrow_to_sql, "3e6 rows - arrowsqlbcp") ] \ No newline at end of file From 840d1b7297b447f8bd06c28111e76c5cf7fc5bc1 Mon Sep 17 00:00:00 2001 From: Russel Webber <24542073+RusselWebber@users.noreply.github.com> Date: Thu, 30 Jan 2025 16:11:53 +0000 Subject: [PATCH 3/6] Ready for the benchmark results --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index cb59a30..ba4d728 100644 --- a/README.md +++ b/README.md @@ -79,10 +79,24 @@ The benchmarks then show the time taken to load using various alternative strate The richbench tables show the min, max and mean time in seconds for the baseline in the left three columns; then the min, max, mean time in seconds for the alternative strategy. +For example this row: + +| Benchmark | Min | Max | Mean | Min (+) | Max (+) | Mean (+) | +| -------------------------------- | --- | --- | ---- | -------- | -------- | -------- | +| 1e3 rows - fast_executemany=True | 1.0 | 1.0 | 1.0 | 0.5 (2x) | 0.5 (2x) | 0.5 (2x) | + +should be interpreted as: the strategy of setting fast_executemany=True resulted in a 2x speedup over the baseline when loading 1000 rows, so fast_executemany=True reduced the average time in seconds to load 1000 rows from 1.0 to 0.5, a 2x speedup. + ### Windows 11 (local db) +tbc + ### Ubuntu (WSL2) (local db) +tbc + +Benchmarks for the typical case of a remote DB still need to be added. + ## Limitations `bulkcopy_from_pandas()` will establish its own database connection to load the data, reusing existing connections and transactions are not supported. From 307f0fdb5764adf17c0527ca45e298416b53f8a7 Mon Sep 17 00:00:00 2001 From: Russel Webber <24542073+RusselWebber@users.noreply.github.com> Date: Thu, 30 Jan 2025 18:38:49 +0000 Subject: [PATCH 4/6] Add Windows benchmarks --- README.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ba4d728..daffb57 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,23 @@ should be interpreted as: the strategy of setting fast_executemany=True resulted ### Windows 11 (local db) -tbc +| Benchmark | Min | Max | Mean | Min (+) | Max (+) | Mean (+) | +| -------------------------------- | ------- | ------- | ------- | --------------- | --------------- | --------------- | +| 1e3 rows - fast_executemany=True | 0.050 | 0.109 | 0.079 | 0.053 (-1.1x) | 0.061 (1.8x) | 0.057 (1.4x) | +| 1e3 rows - bcpandas | 0.054 | 0.058 | 0.056 | 0.142 (-2.6x) | 0.196 (-3.4x) | 0.169 (-3.0x) | +| 1e3 rows - arrowsqlbcp | 0.053 | 0.055 | 0.054 | 0.015 (3.6x) | 0.089 (-1.6x) | 0.052 (1.0x) | +| 1e4 rows - fast_executemany=True | 0.482 | 0.541 | 0.512 | 0.471 (1.0x) | 0.473 (1.1x) | 0.472 (1.1x) | +| 1e4 rows - bcpandas | 0.460 | 0.468 | 0.464 | 0.356 (1.3x) | 0.359 (1.3x) | 0.358 (1.3x) | +| 1e4 rows - arrowsqlbcp | 0.463 | 0.474 | 0.468 | 0.094 (4.9x) | 0.097 (4.9x) | 0.096 (4.9x) | +| 1e5 rows - fast_executemany=True | 4.795 | 4.879 | 4.837 | 4.777 (1.0x) | 4.799 (1.0x) | 4.788 (1.0x) | +| 1e5 rows - bcpandas | 4.689 | 4.759 | 4.724 | 2.574 (1.8x) | 2.967 (1.6x) | 2.771 (1.7x) | +| 1e5 rows - arrowsqlbcp | 4.754 | 4.914 | 4.834 | 0.855 (5.6x) | 0.886 (5.5x) | 0.870 (5.6x) | +| 1e6 rows - fast_executemany=True | 54.914 | 56.384 | 55.649 | 54.161 (1.0x) | 55.123 (1.0x) | 54.642 (1.0x) | +| 1e6 rows - bcpandas | 54.626 | 55.933 | 55.279 | 23.751 (2.3x) | 23.785 (2.4x) | 23.768 (2.3x) | +| 1e6 rows - arrowsqlbcp | 54.733 | 55.558 | 55.145 | 8.307 (6.6x) | 8.401 (6.6x) | 8.354 (6.6x) | +| 3e6 rows - fast_executemany=True | 253.726 | 253.917 | 253.821 | 255.076 (-1.0x) | 255.172 (-1.0x) | 255.124 (-1.0x) | +| 3e6 rows - bcpandas | 255.342 | 259.436 | 257.389 | 69.842 (3.7x) | 70.005 (3.7x) | 69.923 (3.7x) | +| 3e6 rows - arrowsqlbcp | 254.980 | 258.550 | 256.765 | 24.767 (10.3x) | 24.801 (10.4x) | 24.784 (10.4x) | ### Ubuntu (WSL2) (local db) From 2527cb0376a7103456f11c1e97f9adbbcd174ee9 Mon Sep 17 00:00:00 2001 From: Russel Webber <24542073+RusselWebber@users.noreply.github.com> Date: Fri, 31 Jan 2025 13:39:15 +0000 Subject: [PATCH 5/6] Benchmark results --- README.md | 114 ++++++++++++++++++++++++++--------- benchmarks/bench_loadperf.py | 8 +-- src/arrowsqlbcpy/__init__.py | 4 +- 3 files changed, 93 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index daffb57..7cc76b8 100644 --- a/README.md +++ b/README.md @@ -67,49 +67,109 @@ The benchmarks were run using the [richbench](https://github.com/tonybaloney/ric The benchmarks load a 3m row parquet file of New York taxi data. Times are recorded for loading 1000 rows, 10 000 rows, 100 000 rows, 1 000 000 rows and finally all 3 000 000 rows. -The benchmarks have a baseline of using pandas `to_sql()` and SQLAlchemy with pyodbc and pymssql. This is a common solution for loading pandas dataframes into SQL Server. +The benchmarks have a baseline of using pandas `to_sql()` and SQLAlchemy with pyodbc and pymssql. This is a common solution for loading pandas dataframes into SQL Server. A batch size of 10 000 rows was used in the benchmarks. -The benchmarks then show the time taken to load using various alternative strategies: +The benchmarks show the time taken to load using various alternative strategies: | Label | Description | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | fast_executemany=True | Use pandas `to_sql()`, SQLAlchemy, pyodbc, pymssql with the fast_executemany=True option as discussed [here](https://stackoverflow.com/questions/48006551/speeding-up-pandas-dataframe-to-sql-with-fast-executemany-of-pyodbc) | | bcpandas | Use the [bcpandas](https://github.com/yehoshuadimarsky/bcpandas) package to load the dataframes. The package writes temp files and spawns bcp processes to load them | -| arrowsqlbcp | This package using .Net SqlBulkCopy | +| arrowsqlbcpy | This package using .Net SqlBulkCopy | The richbench tables show the min, max and mean time in seconds for the baseline in the left three columns; then the min, max, mean time in seconds for the alternative strategy. For example this row: -| Benchmark | Min | Max | Mean | Min (+) | Max (+) | Mean (+) | -| -------------------------------- | --- | --- | ---- | -------- | -------- | -------- | -| 1e3 rows - fast_executemany=True | 1.0 | 1.0 | 1.0 | 0.5 (2x) | 0.5 (2x) | 0.5 (2x) | +| Benchmark | Min | Max | Mean | Min (+) | Max (+) | Mean (+) | +| ---------------------------------- | --- | --- | ---- | -------- | -------- | -------- | +| 1 000 rows - fast_executemany=True | 1.0 | 1.0 | 1.0 | 0.5 (2x) | 0.5 (2x) | 0.5 (2x) | should be interpreted as: the strategy of setting fast_executemany=True resulted in a 2x speedup over the baseline when loading 1000 rows, so fast_executemany=True reduced the average time in seconds to load 1000 rows from 1.0 to 0.5, a 2x speedup. ### Windows 11 (local db) -| Benchmark | Min | Max | Mean | Min (+) | Max (+) | Mean (+) | -| -------------------------------- | ------- | ------- | ------- | --------------- | --------------- | --------------- | -| 1e3 rows - fast_executemany=True | 0.050 | 0.109 | 0.079 | 0.053 (-1.1x) | 0.061 (1.8x) | 0.057 (1.4x) | -| 1e3 rows - bcpandas | 0.054 | 0.058 | 0.056 | 0.142 (-2.6x) | 0.196 (-3.4x) | 0.169 (-3.0x) | -| 1e3 rows - arrowsqlbcp | 0.053 | 0.055 | 0.054 | 0.015 (3.6x) | 0.089 (-1.6x) | 0.052 (1.0x) | -| 1e4 rows - fast_executemany=True | 0.482 | 0.541 | 0.512 | 0.471 (1.0x) | 0.473 (1.1x) | 0.472 (1.1x) | -| 1e4 rows - bcpandas | 0.460 | 0.468 | 0.464 | 0.356 (1.3x) | 0.359 (1.3x) | 0.358 (1.3x) | -| 1e4 rows - arrowsqlbcp | 0.463 | 0.474 | 0.468 | 0.094 (4.9x) | 0.097 (4.9x) | 0.096 (4.9x) | -| 1e5 rows - fast_executemany=True | 4.795 | 4.879 | 4.837 | 4.777 (1.0x) | 4.799 (1.0x) | 4.788 (1.0x) | -| 1e5 rows - bcpandas | 4.689 | 4.759 | 4.724 | 2.574 (1.8x) | 2.967 (1.6x) | 2.771 (1.7x) | -| 1e5 rows - arrowsqlbcp | 4.754 | 4.914 | 4.834 | 0.855 (5.6x) | 0.886 (5.5x) | 0.870 (5.6x) | -| 1e6 rows - fast_executemany=True | 54.914 | 56.384 | 55.649 | 54.161 (1.0x) | 55.123 (1.0x) | 54.642 (1.0x) | -| 1e6 rows - bcpandas | 54.626 | 55.933 | 55.279 | 23.751 (2.3x) | 23.785 (2.4x) | 23.768 (2.3x) | -| 1e6 rows - arrowsqlbcp | 54.733 | 55.558 | 55.145 | 8.307 (6.6x) | 8.401 (6.6x) | 8.354 (6.6x) | -| 3e6 rows - fast_executemany=True | 253.726 | 253.917 | 253.821 | 255.076 (-1.0x) | 255.172 (-1.0x) | 255.124 (-1.0x) | -| 3e6 rows - bcpandas | 255.342 | 259.436 | 257.389 | 69.842 (3.7x) | 70.005 (3.7x) | 69.923 (3.7x) | -| 3e6 rows - arrowsqlbcp | 254.980 | 258.550 | 256.765 | 24.767 (10.3x) | 24.801 (10.4x) | 24.784 (10.4x) | - -### Ubuntu (WSL2) (local db) - -tbc +**Summary results** + +| Label | # rows | Avg Speedup | Avg Time (s) | +| --------------------- | --------- | ----------- | ------------ | +| **arrowsqlbcpy** | 1 000 | **-1.9x** | 0.106 | +| **arrowsqlbcpy** | 10 000 | **4.9x** | 0.101 | +| **arrowsqlbcpy** | 100 000 | **4.9x** | 0.933 | +| **arrowsqlbcpy** | 1 000 000 | **5.3x** | 8.864 | +| **arrowsqlbcpy** | 3 000 000 | **7.6x** | 26.048 | +| bcpandas | 1 000 | -3.6x | 0.156 | +| bcpandas | 10 000 | 1.5x | 0.336 | +| bcpandas | 100 000 | 1.8x | 2.567 | +| bcpandas | 1 000 000 | 1.9x | 24.627 | +| bcpandas | 3 000 000 | 2.7x | 72.353 | +| fast_executemany=True | 1 000 | 2.4x | 0.035 | +| fast_executemany=True | 10 000 | 2.3x | 0.235 | +| fast_executemany=True | 100 000 | 2.3x | 2.246 | +| fast_executemany=True | 1 000 000 | 2.1x | 22.044 | +| fast_executemany=True | 3 000 000 | 3.0x | 65.344 | + +**Detailed richbench results** + +| Benchmark | Min | Max | Mean | Min (+) | Max (+) | Mean (+) | +| -------------------------------------- | ------- | ------- | ------- | ------------- | ------------- | ------------- | +| 1 000 - arrowsqlbcp | 0.053 | 0.056 | 0.055 | 0.015 (3.6x) | 0.198 (-3.5x) | 0.106 (-1.9x) | +| 10 000 rows - arrowsqlbcp | 0.489 | 0.502 | 0.495 | 0.099 (4.9x) | 0.103 (4.9x) | 0.101 (4.9x) | +| 100 000 rows - arrowsqlbcp | 4.587 | 4.616 | 4.601 | 0.922 (5.0x) | 0.944 (4.9x) | 0.933 (4.9x) | +| 1 000 000 rows - arrowsqlbcp | 46.558 | 46.738 | 46.648 | 8.842 (5.3x) | 8.886 (5.3x) | 8.864 (5.3x) | +| 3 000 000 rows - arrowsqlbcp | 198.464 | 198.676 | 198.570 | 26.016 (7.6x) | 26.079 (7.6x) | 26.048 (7.6x) | +| 1 000 - bcpandas | 0.051 | 0.052 | 0.052 | 0.121 (-2.4x) | 0.190 (-3.6x) | 0.156 (-3.0x) | +| 10 000 rows - bcpandas | 0.499 | 0.500 | 0.500 | 0.333 (1.5x) | 0.339 (1.5x) | 0.336 (1.5x) | +| 100 000 rows - bcpandas | 4.543 | 4.547 | 4.545 | 2.565 (1.8x) | 2.570 (1.8x) | 2.567 (1.8x) | +| 1 000 000 rows - bcpandas | 45.298 | 46.443 | 45.871 | 24.581 (1.8x) | 24.674 (1.9x) | 24.627 (1.9x) | +| 3 000 000 rows - bcpandas | 197.292 | 197.699 | 197.496 | 72.301 (2.7x) | 72.405 (2.7x) | 72.353 (2.7x) | +| 1 000 - fast_executemany=True | 0.052 | 0.116 | 0.084 | 0.030 (1.7x) | 0.041 (2.9x) | 0.035 (2.4x) | +| 10 000 rows - fast_executemany=True | 0.513 | 0.550 | 0.531 | 0.233 (2.2x) | 0.237 (2.3x) | 0.235 (2.3x) | +| 100 000 rows - fast_executemany=True | 5.018 | 5.374 | 5.196 | 2.239 (2.2x) | 2.253 (2.4x) | 2.246 (2.3x) | +| 1 000 000 rows - fast_executemany=True | 45.470 | 45.582 | 45.526 | 22.036 (2.1x) | 22.051 (2.1x) | 22.044 (2.1x) | +| 3 000 000 rows - fast_executemany=True | 194.152 | 194.523 | 194.337 | 65.153 (3.0x) | 65.534 (3.0x) | 65.344 (3.0x) | + +### Ubuntu (WSL2) (local db in docker container) + +**Summary results** + +| Label | # rows | Avg Speedup | Avg Time (s) | +| --------------------- | --------- | ----------- | ------------ | +| **arrowsqlbcpy** | 1 000 | **-2.2x** | 0.154 | +| **arrowsqlbcpy** | 10 000 | **4.2x** | 0.120 | +| **arrowsqlbcpy** | 100 000 | **4.7x** | 1.070 | +| **arrowsqlbcpy** | 1 000 000 | **4.7x** | 10.572 | +| **arrowsqlbcpy** | 3 000 000 | **6.8x** | 30.673 | +| bcpandas | 1 000 | -2.4x | 0.158 | +| bcpandas | 10 000 | 1.2x | 0.438 | +| bcpandas | 100 000 | 1.5x | 3.383 | +| bcpandas | 1 000 000 | 1.5x | 32.774 | +| bcpandas | 3 000 000 | 2.2x | 95.200 | +| fast_executemany=True | 1 000 | 1.6x | 0.059 | +| fast_executemany=True | 10 000 | 1.7x | 0.323 | +| fast_executemany=True | 100 000 | 1.6x | 3.039 | +| fast_executemany=True | 1 000 000 | 1.7x | 29.810 | +| fast_executemany=True | 3 000 000 | 2.4x | 87.419 | + +**Detailed richbench results** + +| Benchmark | Min | Max | Mean | Min (+) | Max (+) | Mean (+) | +| -------------------------------------- | ------- | ------- | ------- | ------------- | ------------- | ------------- | +| 1 000 - arrowsqlbcp | 0.069 | 0.071 | 0.070 | 0.028 (2.4x) | 0.280 (-3.9x) | 0.154 (-2.2x) | +| 10 000 rows - arrowsqlbcp | 0.503 | 0.510 | 0.506 | 0.115 (4.4x) | 0.126 (4.0x) | 0.120 (4.2x) | +| 100 000 rows - arrowsqlbcp | 5.062 | 5.085 | 5.074 | 1.064 (4.8x) | 1.076 (4.7x) | 1.070 (4.7x) | +| 1 000 000 rows - arrowsqlbcp | 49.746 | 50.433 | 50.089 | 10.566 (4.7x) | 10.578 (4.8x) | 10.572 (4.7x) | +| 3 000 000 rows - arrowsqlbcp | 208.669 | 208.953 | 208.811 | 30.364 (6.9x) | 30.982 (6.7x) | 30.673 (6.8x) | +| 1 000 - bcpandas | 0.066 | 0.068 | 0.067 | 0.149 (-2.2x) | 0.167 (-2.5x) | 0.158 (-2.4x) | +| 10 000 rows - bcpandas | 0.500 | 0.508 | 0.504 | 0.431 (1.2x) | 0.444 (1.1x) | 0.438 (1.2x) | +| 100 000 rows - bcpandas | 5.016 | 5.028 | 5.022 | 3.369 (1.5x) | 3.397 (1.5x) | 3.383 (1.5x) | +| 1 000 000 rows - bcpandas | 49.771 | 50.535 | 50.153 | 32.603 (1.5x) | 32.945 (1.5x) | 32.774 (1.5x) | +| 3 000 000 rows - bcpandas | 208.104 | 208.350 | 208.227 | 95.057 (2.2x) | 95.343 (2.2x) | 95.200 (2.2x) | +| 1 000 - fast_executemany=True | 0.068 | 0.116 | 0.092 | 0.049 (1.4x) | 0.069 (1.7x) | 0.059 (1.6x) | +| 10 000 rows - fast_executemany=True | 0.514 | 0.557 | 0.535 | 0.322 (1.6x) | 0.324 (1.7x) | 0.323 (1.7x) | +| 100 000 rows - fast_executemany=True | 4.934 | 4.961 | 4.948 | 3.023 (1.6x) | 3.056 (1.6x) | 3.039 (1.6x) | +| 1 000 000 rows - fast_executemany=True | 49.298 | 50.658 | 49.978 | 29.783 (1.7x) | 29.836 (1.7x) | 29.810 (1.7x) | +| 3 000 000 rows - fast_executemany=True | 207.245 | 213.096 | 210.171 | 87.219 (2.4x) | 87.620 (2.4x) | 87.419 (2.4x) | Benchmarks for the typical case of a remote DB still need to be added. diff --git a/benchmarks/bench_loadperf.py b/benchmarks/bench_loadperf.py index 5f6f351..27a96cf 100644 --- a/benchmarks/bench_loadperf.py +++ b/benchmarks/bench_loadperf.py @@ -7,7 +7,7 @@ cn = r"Server=PC\SQLEXPRESS;Database=test;Trusted_Connection=True;Encrypt=false;" tablename = "test" -max_chunksize = None +max_chunksize = 10_000 df = pd.read_parquet(r"C:\Users\russe\Downloads\yellow_tripdata_2024-01.parquet") connection_url = URL.create( @@ -38,20 +38,20 @@ def fast_executemany__to_sql(nrows=None): with fast_executemany_engine.begin() as conn: conn.execute(text(f"TRUNCATE TABLE {tablename}")) local_df = df.iloc[:nrows] if nrows else df - with engine.begin() as conn: + with fast_executemany_engine.begin() as conn: local_df.to_sql(name=tablename, con=conn, index=False, chunksize=max_chunksize, if_exists="append") def arrow_to_sql(nrows=None): with engine.begin() as conn: conn.execute(text(f"TRUNCATE TABLE {tablename}")) local_df = df.iloc[:nrows] if nrows else df - bulkcopy_from_pandas(local_df, cn, tablename) + bulkcopy_from_pandas(local_df, cn, tablename, max_chunksize=max_chunksize) def bcpandas_to_sql(nrows=None): with engine.begin() as conn: conn.execute(text(f"TRUNCATE TABLE {tablename}")) local_df = df.iloc[:nrows] if nrows else df - to_sql(local_df, tablename, creds, index=False, if_exists="append") + to_sql(local_df, tablename, creds, index=False, if_exists="append", batch_size=min(max_chunksize, local_df.shape[0])) default_to_sql_1000 = partial(default_to_sql, 1_000) fast_executemany__to_sql_1000 = partial(fast_executemany__to_sql, 1_000) diff --git a/src/arrowsqlbcpy/__init__.py b/src/arrowsqlbcpy/__init__.py index 2477f28..7d6a1b0 100644 --- a/src/arrowsqlbcpy/__init__.py +++ b/src/arrowsqlbcpy/__init__.py @@ -14,11 +14,11 @@ sqllibname = "Microsoft.Data.SqlClient.SNI.dll" elif is_mac: libname = "ArrowSqlBulkCopyNet.dylib" - sqllibname = None + sqllibname = None else: libname = "ArrowSqlBulkCopyNet.so" sqllibname = None - + func_name = "write" error_size = 1000 From 0e48661a9b92887e41a23e13eb8b24f6e38f5ad1 Mon Sep 17 00:00:00 2001 From: Russel Webber <24542073+RusselWebber@users.noreply.github.com> Date: Fri, 31 Jan 2025 15:17:03 +0000 Subject: [PATCH 6/6] Reformat results and add plot --- README.md | 90 +++++++++++++++++++----------------------------- performance.png | Bin 0 -> 38421 bytes 2 files changed, 35 insertions(+), 55 deletions(-) create mode 100644 performance.png diff --git a/README.md b/README.md index 7cc76b8..65bd864 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ A tiny library that uses .Net SqlBulkCopy to enable fast data loading to Microso This library is _much_ faster than any other Python solution, including bcpandas, pyodbc and pymssql. See the benchmark results below. +![Performance plot](performance.png) + ## Installation Binary wheels are available from PyPi and can be installed using your preferred package manager: @@ -16,27 +18,6 @@ or > uv add arrowsqlbcpy -## Requirements - -Wheels are available for the latest versions of Windows 64 bit, MacOS ARM 64bit and Ubuntu 64 bit. - -Wheels are available for Python 3.9-3.13. - -### Linux support - -The Ubuntu wheels _may_ work on other Linux distros. Building C# native libaries and then packaging appropriately for multiple Linux distros is not straightforward. The simplest solution for most Linux distros is to simply pull the source from Github and build locally. These are the high-level steps: - -1. Install .net - https://learn.microsoft.com/en-us/dotnet/core/install/linux -2. Clone the source - > git clone https://github.com/RusselWebber/arrowsqlbcpy -3. Install uv - https://docs.astral.sh/uv/getting-started/installation/ -4. Build the wheel locally - > uv build --wheel -5. Install the wheel - > pip install dist/wheel_file.whl - ## Usage Connection strings for .Net are documented [here](https://www.connectionstrings.com/microsoft-data-sqlclient/) @@ -59,6 +40,27 @@ bulkcopy_from_pandas(df, cn, tablename) When testing it can be useful to have pandas create the table for you, see [tests/test_load.py](https://github.com/RusselWebber/arrowsqlbcpy/blob/main/tests/test_load.py) for an example. +## Requirements + +Wheels are available for the latest versions of Windows 64 bit, MacOS ARM 64bit and Ubuntu 64 bit. + +Wheels are available for Python 3.9-3.13. + +### Linux support + +The Ubuntu wheels _may_ work on other Linux distros. Building C# native libaries and then packaging appropriately for multiple Linux distros is not straightforward. The simplest solution for most Linux distros is to simply pull the source from Github and build locally. These are the high-level steps: + +1. Install .net + https://learn.microsoft.com/en-us/dotnet/core/install/linux +2. Clone the source + > git clone https://github.com/RusselWebber/arrowsqlbcpy +3. Install uv + https://docs.astral.sh/uv/getting-started/installation/ +4. Build the wheel locally + > uv build --wheel +5. Install the wheel + > pip install dist/wheel_file.whl + ## Benchmarks The benchmarks were run using the [richbench](https://github.com/tonybaloney/rich-bench) package. Tests were run repeatedly to get stable benchmarks. @@ -91,23 +93,12 @@ should be interpreted as: the strategy of setting fast_executemany=True resulted **Summary results** -| Label | # rows | Avg Speedup | Avg Time (s) | -| --------------------- | --------- | ----------- | ------------ | -| **arrowsqlbcpy** | 1 000 | **-1.9x** | 0.106 | -| **arrowsqlbcpy** | 10 000 | **4.9x** | 0.101 | -| **arrowsqlbcpy** | 100 000 | **4.9x** | 0.933 | -| **arrowsqlbcpy** | 1 000 000 | **5.3x** | 8.864 | -| **arrowsqlbcpy** | 3 000 000 | **7.6x** | 26.048 | -| bcpandas | 1 000 | -3.6x | 0.156 | -| bcpandas | 10 000 | 1.5x | 0.336 | -| bcpandas | 100 000 | 1.8x | 2.567 | -| bcpandas | 1 000 000 | 1.9x | 24.627 | -| bcpandas | 3 000 000 | 2.7x | 72.353 | -| fast_executemany=True | 1 000 | 2.4x | 0.035 | -| fast_executemany=True | 10 000 | 2.3x | 0.235 | -| fast_executemany=True | 100 000 | 2.3x | 2.246 | -| fast_executemany=True | 1 000 000 | 2.1x | 22.044 | -| fast_executemany=True | 3 000 000 | 3.0x | 65.344 | +| | 1000 | 10000 | 10000 | 1000000 | 3000000 | +| --------------------- | ---------------- | ---------------- | ---------------- | ---------------- | ----------------- | +| df.to_sql() | 0.055 | 0.495 | 4.601 | 46.648 | 198.57 | +| arrowsqlbcpy | 0.106 (-1.9x) | **0.101 (4.9x)** | **0.933 (4.9x)** | **8.864 (5.3x)** | **26.048 (7.6x)** | +| bcpandas | 0.156 (-3.0x) | 0.336 (1.5x) | 2.567 (1.8x) | 24.627 (1.9x) | 72.353 (2.7x) | +| fast_executemany=True | **0.035 (2.4x)** | 0.235 (2.3x) | 2.246 (2.3x) | 22.044 (2.1x) | 65.344 (3.0x) | **Detailed richbench results** @@ -133,23 +124,12 @@ should be interpreted as: the strategy of setting fast_executemany=True resulted **Summary results** -| Label | # rows | Avg Speedup | Avg Time (s) | -| --------------------- | --------- | ----------- | ------------ | -| **arrowsqlbcpy** | 1 000 | **-2.2x** | 0.154 | -| **arrowsqlbcpy** | 10 000 | **4.2x** | 0.120 | -| **arrowsqlbcpy** | 100 000 | **4.7x** | 1.070 | -| **arrowsqlbcpy** | 1 000 000 | **4.7x** | 10.572 | -| **arrowsqlbcpy** | 3 000 000 | **6.8x** | 30.673 | -| bcpandas | 1 000 | -2.4x | 0.158 | -| bcpandas | 10 000 | 1.2x | 0.438 | -| bcpandas | 100 000 | 1.5x | 3.383 | -| bcpandas | 1 000 000 | 1.5x | 32.774 | -| bcpandas | 3 000 000 | 2.2x | 95.200 | -| fast_executemany=True | 1 000 | 1.6x | 0.059 | -| fast_executemany=True | 10 000 | 1.7x | 0.323 | -| fast_executemany=True | 100 000 | 1.6x | 3.039 | -| fast_executemany=True | 1 000 000 | 1.7x | 29.810 | -| fast_executemany=True | 3 000 000 | 2.4x | 87.419 | +| | 1000 | 10000 | 10000 | 1000000 | 3000000 | +| --------------------- | ---------------- | ---------------- | ---------------- | ----------------- | ----------------- | +| df.to_sql() | 0.070 | 0.506 | 5.074 | 50.089 | 208.811 | +| arrowsqlbcpy | 0.154 (-2.2x) | **0.120 (4.2x)** | **1.070 (4.7x)** | **10.572 (4.7x)** | **30.673 (6.8x)** | +| bcpandas | 0.158 (-2.4x) | 0.438 (1.2x) | 3.383 (1.5x) | 32.774 (1.5x) | 95.200 (2.2x) | +| fast_executemany=True | **0.059 (1.6x)** | 0.323 (1.7x) | 3.039 (1.6x) | 29.810 (1.7x) | 87.419 (2.4x) | **Detailed richbench results** diff --git a/performance.png b/performance.png new file mode 100644 index 0000000000000000000000000000000000000000..1c00c1315efec745c15bd50d374b1832c3f4e253 GIT binary patch literal 38421 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%0DQ*U?(> z*2>i%e8HJ7ElrmwO!2qERKm@NTG;g`uwpd<8!Gn!QA|Qo(om3wL&mj$#F!HbRe_F| zbqAo#bJqX&f*G>lsO#Xu{M zPe?FxZA6(@1KQGxB5o{`8qn;!&ZuavznL62>rIG+5|@d&$E*!c>e${6b_;P8z4k?WQroq&ChCW$=4~=s?UAL|;D~0BUMe--8ewU!o;i|8q09Szu?D ze)wTrsI9A8U{X@7T?jjJMu=j$Wq}0E-PdNCfNQ6Jru#(tlQSW$0evr@f1_=T*j!Zz9a!z=uG zQY?|POFL@D(I>@oLPA1C*0J$VEfrvTT8oK}%**$ggisku2=;1fnhu)ViV)rCv)P@j zD4lUeQI%{_gRInr!&Y4GqMt{=u7%b5fiwmzk-0d2~+(W8<@FlDJJ<_
^j520hp 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 N@O1TaS?83{1OUHcJ7E9- literal 0 HcmV?d00001