Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/publish-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.9'
python-version: '3.12'
- name: Install dependencies
run: python -m pip install .[dev]
- name: Build
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # pin@v5.6.0
with:
python-version: '3.10'
python-version: '3.12'
- name: Install dependencies
run: python -m pip install -r requirements.txt .[dev]
- name: Build package
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.9
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.9"
python-version: "3.12"
- name: Install dependencies
run: python -m pip install .[qa]
- name: Linting by ruff
Expand All @@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.9, '3.10', '3.11', '3.12']
python-version: ['3.10', '3.11', '3.12', '3.13']

steps:
- uses: actions/checkout@v4
Expand Down
73 changes: 73 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

locopy is a Python library for ETL processing with Amazon Redshift (`COPY`/`UNLOAD`) and Snowflake (`COPY INTO`). It wraps boto3 for S3 operations and is DB-API 2.0 adapter agnostic (tested with psycopg2, pg8000, snowflake-connector-python). Supports Python 3.10-3.14.

## Common Commands

### Install for development
```bash
pip install .[dev,psycopg2,pg8000,snowflake]
# Test data setup (needed for tests):
cp tests/data/.locopyrc ~/.locopyrc
cp tests/data/.locopy-sfrc ~/.locopy-sfrc
```

### Run tests
```bash
make not_integration # Unit tests only (default CI target)
make coverage # All tests with coverage
pytest tests/test_utility.py # Single test file
pytest tests/test_utility.py::test_find_column_type -v # Single test
pytest -m 'not integration' # Skip integration tests (same as make not_integration)
```

### Lint and format
```bash
ruff check # Lint
ruff check --fix # Lint with auto-fix
ruff format --check # Check formatting
ruff format # Auto-format
```

### Build docs
```bash
make sphinx
```

### Dependency version bumps (edgetest)
Edgetest config is in `pyproject.toml` under `[edgetest.envs.core]`. It bumps upper bounds for boto3, PyYAML, pandas, numpy. The CI workflow runs weekly and creates PRs with updated `pyproject.toml` and `requirements.txt`. The lockfile is generated via `uv pip compile --output-file=requirements.txt pyproject.toml`.

## Architecture

```
locopy/
├── database.py # Database - base class for DB connections (connect, execute, to_dataframe)
├── s3.py # S3 - boto3 wrapper for upload/download/delete on S3 buckets
├── redshift.py # Redshift(S3, Database) - multiple inheritance, adds COPY/UNLOAD + load_and_copy/unload_and_copy
├── snowflake.py # Snowflake(S3, Database) - multiple inheritance, adds COPY INTO + internal stage support
├── utility.py # Helpers: file splitting, compression, YAML config reading, column type detection
├── errors.py # Custom exception hierarchy: LocopyError, DBError, S3Error (each with sub-exceptions)
├── logger.py # Logging setup
└── _version.py # Single source of version (__version__)
```

**Key inheritance pattern:** Both `Redshift` and `Snowflake` use multiple inheritance from `S3` and `Database`. The `S3` class handles AWS session/credentials and file transfer. `Database` handles DB connection lifecycle and query execution. The subclasses override `connect()` to set up both S3 and DB connections.

**Column type detection:** `utility.py` has `find_column_type` as a `@singledispatch` function with separate implementations for pandas (`find_column_type_pandas`) and polars (`find_column_type_polars`) DataFrames. When bumping pandas/polars versions, watch for dtype representation changes (e.g., pandas 3.0 changed string dtype from `object` to `StringDtype` and datetime resolution from `ns` to `us`).

**Version** is defined in `locopy/_version.py` and read dynamically by setuptools via `pyproject.toml` (`[tool.setuptools.dynamic]`).

## Code Style

- Linter/formatter: **ruff** (config in `pyproject.toml`). Pre-commit hooks enforce ruff + trailing whitespace + debug statements.
- Docstring convention: **numpy style** (`[tool.ruff.lint.pydocstyle] convention = "numpy"`)
- Relative imports are banned (`ban-relative-imports = "all"`)
- Target Python version: 3.12 (ruff target)

## Test Markers

- `@pytest.mark.integration` - Integration tests requiring real DB/S3 connections (skipped in CI unit test runs)
2 changes: 1 addition & 1 deletion locopy/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@
# See the License for the specific language governing permissions and
# limitations under the License.

__version__ = "0.6.8"
__version__ = "0.7.0"
14 changes: 7 additions & 7 deletions locopy/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"""Database Module."""

import time
from typing import Dict, Generator, List, Optional, Union
from typing import Dict, Generator, List

import pandas
import polars
Expand Down Expand Up @@ -73,8 +73,8 @@ class Database:
def __init__(
self,
dbapi: object,
config_yaml: Optional[str] = None,
**kwargs: Union[str, int],
config_yaml: str | None = None,
**kwargs: str | int,
) -> None:
self.dbapi = dbapi
self.connection = kwargs or {}
Expand Down Expand Up @@ -205,8 +205,8 @@ def column_names(self) -> List[str]:
return [column[0].lower() for column in self.cursor.description]

def to_dataframe(
self, df_type: str = "pandas", size: Optional[int] = None
) -> Optional[Union[pandas.DataFrame, polars.DataFrame]]:
self, df_type: str = "pandas", size: int | None = None
) -> pandas.DataFrame | polars.DataFrame | None:
"""Return a dataframe of the last query results.

Parameters
Expand Down Expand Up @@ -244,7 +244,7 @@ def to_dataframe(
elif df_type == "polars":
return polars.DataFrame(fetched, schema=columns, orient="row")

def to_dict(self) -> Generator[Dict[str, Union[str, int, float]], None, None]:
def to_dict(self) -> Generator[Dict[str, str | int | float], None, None]:
"""Generate dictionaries of rows.

Yields
Expand All @@ -254,7 +254,7 @@ def to_dict(self) -> Generator[Dict[str, Union[str, int, float]], None, None]:
"""
columns = self.column_names()
for row in self.cursor:
yield dict(zip(columns, row))
yield dict(zip(columns, row, strict=False))

def _is_connected(self) -> bool:
"""Check the connection and cursor class arrtributes are initalized.
Expand Down
10 changes: 5 additions & 5 deletions locopy/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"""

import os
from typing import List, Optional, Tuple
from typing import List, Tuple

from boto3 import Session
from boto3.s3.transfer import TransferConfig
Expand Down Expand Up @@ -88,7 +88,7 @@ class S3:
"""

def __init__(
self, profile: Optional[str] = None, kms_key: Optional[str] = None, **kwargs
self, profile: str | None = None, kms_key: str | None = None, **kwargs
) -> None:
self.profile = profile
self.kms_key = kms_key
Expand Down Expand Up @@ -153,7 +153,7 @@ def _generate_s3_path(self, bucket: str, key: str) -> str:
"""
return f"s3://{bucket}/{key}"

def _generate_unload_path(self, bucket: str, folder: Optional[str]) -> str:
def _generate_unload_path(self, bucket: str, folder: str | None) -> str:
"""Return the S3 file URL.

If a valid (not None) folder is provided, returns in the format s3://bucket/folder.
Expand Down Expand Up @@ -226,7 +226,7 @@ def upload_to_s3(self, local: str, bucket: str, key: str) -> None:
raise S3UploadError("Error uploading to S3.") from e

def upload_list_to_s3(
self, local_list: List[str], bucket: str, folder: Optional[str] = None
self, local_list: List[str], bucket: str, folder: str | None = None
) -> List[str]:
"""
Upload a list of files to a S3 bucket.
Expand Down Expand Up @@ -300,7 +300,7 @@ def download_from_s3(self, bucket: str, key: str, local: str) -> None:
raise S3DownloadError("Error downloading from S3.") from e

def download_list_from_s3(
self, s3_list: List[str], local_path: Optional[str] = None
self, s3_list: List[str], local_path: str | None = None
) -> List[str]:
"""
Download a list of files from s3.
Expand Down
18 changes: 9 additions & 9 deletions locopy/utility.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from collections import OrderedDict
from functools import singledispatch
from itertools import cycle
from typing import Dict, List, Union
from typing import Dict, List

import pandas as pd
import polars as pl
Expand All @@ -47,7 +47,7 @@


def write_file(
data: List[List[Union[str, int, float]]],
data: List[List[str | int | float]],
delimiter: str,
filepath: str,
mode: str = "w",
Expand Down Expand Up @@ -225,7 +225,7 @@ def concatenate_files(
raise LocopyConcatError("Error concateneating files.") from e


def read_config_yaml(config_yaml: Union[str, object]) -> Dict[str, Union[str, int]]:
def read_config_yaml(config_yaml: str | object) -> Dict[str, str | int]:
"""Read a configuration YAML file.

Populate the database connection attributes, and validate required ones.
Expand Down Expand Up @@ -357,13 +357,13 @@ def check_column_type_pyarrow(pa_dtype):
datatype = check_column_type_pyarrow(data.dtype.pyarrow_dtype)
column_type.append(datatype)
else:
if (data.dtype in ["datetime64[ns]", "M8[ns]"]) or (
re.match(r"(datetime64\[ns\,\W)([a-zA-Z]+)(\])", str(data.dtype))
):
if pd.api.types.is_datetime64_any_dtype(data.dtype):
column_type.append("timestamp")
elif str(data.dtype).lower().startswith("bool"):
column_type.append("boolean")
elif str(data.dtype).startswith("object"):
elif str(data.dtype).startswith("object") or isinstance(
data.dtype, pd.StringDtype
):
data_type = validate_float_object(data) or validate_date_object(data)
if not data_type:
column_type.append("varchar")
Expand All @@ -376,7 +376,7 @@ def check_column_type_pyarrow(pa_dtype):
else:
column_type.append("varchar")
logger.info("Parsing column %s to %s", column, column_type[-1])
return OrderedDict(zip(list(dataframe.columns), column_type))
return OrderedDict(zip(list(dataframe.columns), column_type, strict=False))


@find_column_type.register(pl.DataFrame)
Expand Down Expand Up @@ -467,7 +467,7 @@ def validate_float_object(column):
else:
column_type.append(data_type)
logger.info("Parsing column %s to %s", column, column_type[-1])
return OrderedDict(zip(list(dataframe.columns), column_type))
return OrderedDict(zip(list(dataframe.columns), column_type, strict=False))


class ProgressPercentage:
Expand Down
Loading
Loading