From e522e7da0e8b3d639d0498803f0088ca6bdfb436 Mon Sep 17 00:00:00 2001 From: tech4242 <5933291+tech4242@users.noreply.github.com> Date: Sun, 28 Dec 2025 20:05:14 +0100 Subject: [PATCH 1/4] add: new core features --- .github/workflows/ci.yml | 97 ++++++++++++++++++ .github/workflows/release.yml | 88 ++++++++++++++++ README.md | 107 +++++++++++++++++--- codecov.yml | 15 +++ csv2vcard/__init__.py | 2 +- csv2vcard/cli.py | 57 +++++++++-- csv2vcard/create_vcard.py | 161 ++++++++++++++++++++++++++---- csv2vcard/csv2vcard.py | 75 +++++++++++--- csv2vcard/export_vcard.py | 42 ++++++++ csv2vcard/mapping.py | 150 ++++++++++++++++++++++++++++ csv2vcard/models.py | 132 ++++++++++++++++++++++-- csv2vcard/parse_csv.py | 139 +++++++++++++++++++++++++- pyproject.toml | 5 +- tests/conftest.py | 16 +-- tests/test_cli.py | 4 +- tests/test_create_vcard.py | 40 ++++---- tests/test_csv2vcard.py | 10 +- tests/test_mapping.py | 182 ++++++++++++++++++++++++++++++++++ tests/test_models.py | 16 ++- 19 files changed, 1232 insertions(+), 106 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 codecov.yml create mode 100644 csv2vcard/mapping.py create mode 100644 tests/test_mapping.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7eac58a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,97 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install ruff + run: | + python -m pip install --upgrade pip + pip install ruff + + - name: Run ruff + run: | + ruff check . + + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run tests with coverage + run: | + python -m pytest -v --cov=csv2vcard --cov-report=xml --cov-report=term + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: ./coverage.xml + flags: unittests + name: codecov-${{ matrix.python-version }} + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true + + build: + runs-on: ubuntu-latest + needs: [lint, test] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build package + run: | + python -m build + + - name: Check package with twine + run: | + twine check dist/* + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + retention-days: 7 + + - name: List build contents + run: | + echo "Built packages:" + ls -la dist/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ce4d87e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +name: Release + +on: + release: + types: [published] + +permissions: + contents: write + id-token: write + +jobs: + publish: + name: Build and Publish to PyPI + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Extract version from tag + id: get_version + run: | + TAG="${{ github.event.release.tag_name }}" + VERSION="${TAG#v}" + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "TAG=$TAG" >> $GITHUB_OUTPUT + echo "Release version: $VERSION (from tag: $TAG)" + + - name: Verify package version matches tag + run: | + PACKAGE_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") + echo "Package version: $PACKAGE_VERSION" + echo "Tag version: ${{ steps.get_version.outputs.VERSION }}" + + if [ "$PACKAGE_VERSION" != "${{ steps.get_version.outputs.VERSION }}" ]; then + echo "Error: Package version ($PACKAGE_VERSION) doesn't match tag version (${{ steps.get_version.outputs.VERSION }})" + echo "Please update the version in pyproject.toml to match the release tag" + exit 1 + fi + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build package + run: | + python -m build + echo "Built packages:" + ls -la dist/ + + - name: Check package with twine + run: | + twine check dist/* + + - name: Publish to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: | + twine upload dist/* + + - name: Upload release assets + uses: softprops/action-gh-release@v1 + with: + files: | + dist/*.whl + dist/*.tar.gz + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Post-release summary + run: | + echo "### Release Published Successfully!" + echo "" + echo "**Version:** ${{ steps.get_version.outputs.VERSION }}" + echo "**Tag:** ${{ steps.get_version.outputs.TAG }}" + echo "" + echo "**PyPI:** https://pypi.org/project/csv2vcard/${{ steps.get_version.outputs.VERSION }}/" + echo "" + echo "**Artifacts:**" + ls -lh dist/ + echo "" + echo "Install with: \`pip install csv2vcard==${{ steps.get_version.outputs.VERSION }}\`" diff --git a/README.md b/README.md index 3b796f8..7c16c2c 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ Create vCards from a spreadsheet of contacts - useful for business cards, QR cod ## Features - **vCard 3.0 and 4.0 support** - Generate either format +- **Custom CSV mapping** - Map any CSV column names to vCard fields +- **Batch processing** - Convert entire directories of CSV files +- **Single-file output** - Combine all contacts into one .vcf file +- **Auto-detect encoding** - Handles various file encodings - **Command-line interface** - Convert files directly from terminal - **Library API** - Use programmatically in your Python code - **Type hints** - Full typing support for IDE autocomplete @@ -34,8 +38,11 @@ pip install csv2vcard # With CLI support pip install csv2vcard[cli] -# Development installation -pip install csv2vcard[dev] +# With encoding detection +pip install csv2vcard[encoding] + +# Full installation +pip install csv2vcard[all] ``` ## Quick Start @@ -49,8 +56,17 @@ csv2vcard convert contacts.csv # Specify output directory and vCard version csv2vcard convert contacts.csv -o ./vcards -V 4.0 -# Use semicolon delimiter -csv2vcard convert contacts.csv -d ";" +# Convert all CSVs in a directory +csv2vcard convert ./csv_folder/ + +# Export all contacts to a single file +csv2vcard convert contacts.csv --single-vcard + +# Use custom column mapping +csv2vcard convert data.csv -m mapping.json + +# Show example mapping file +csv2vcard mapping # Create a test vCard (Forrest Gump) csv2vcard test @@ -72,41 +88,72 @@ csv2vcard( ",", output_dir="./vcards", version=VCardVersion.V4_0, + single_file=True, # All contacts in one file + mapping_file="mapping.json", # Custom column names ) +# Convert entire directory +csv2vcard("./csv_folder/", ",", output_dir="./vcards") + # Test with sample contact test_csv2vcard() ``` ## CSV Format -Your CSV file should have these column headers: +Your CSV file should have column headers that match vCard fields. Use the default names or create a custom mapping. + +### Default Column Names ``` -last_name,first_name,title,org,phone,email,website,street,city,p_code,country +last_name,first_name,middle_name,name_prefix,name_suffix,nickname,gender,birthday,anniversary,phone,email,website,org,title,role,street,city,region,p_code,country,note ``` -**Required columns:** `last_name`, `first_name` +**Required:** `last_name`, `first_name` -**Optional columns:** `title`, `org`, `phone`, `email`, `website`, `street`, `city`, `p_code`, `country` +**Optional:** All other fields ### Example CSV ```csv -last_name,first_name,title,org,phone,email,website,street,city,p_code,country -Gump,Forrest,Shrimp Man,Bubba Gump Shrimp Co.,+1234567890,forrest@example.com,https://example.com,42 Plantation St.,Baytown,30314,USA -Doe,Jane,Developer,Tech Corp,+0987654321,jane@example.com,https://jane.dev,123 Main St.,New York,10001,USA +last_name,first_name,title,org,phone,email,street,city,p_code,country,birthday,note +Gump,Forrest,Shrimp Man,Bubba Gump Shrimp Co.,+1234567890,forrest@example.com,42 Plantation St.,Baytown,30314,USA,1944-06-06,Life is like a box of chocolates +Doe,Jane,Developer,Tech Corp,+0987654321,jane@example.com,123 Main St.,New York,10001,USA,, +``` + +### Custom Column Mapping + +Create a JSON file to map your CSV column names to vCard fields: + +```json +{ + "first_name": ["Given Name", "FirstName", "First"], + "last_name": ["Surname", "FamilyName", "Last"], + "email": ["Email Address", "E-Mail"], + "phone": ["Phone Number", "Mobile", "Tel"] +} +``` + +Then use it: +```bash +csv2vcard convert data.csv -m mapping.json ``` ## CLI Reference ``` -csv2vcard convert [OPTIONS] CSV_FILE +csv2vcard convert [OPTIONS] SOURCE + +Arguments: + SOURCE Path to CSV file or directory containing CSV files Options: -d, --delimiter TEXT CSV field delimiter (default: ",") -o, --output PATH Output directory (default: ./export/) -V, --vcard-version TEXT vCard version: 3.0 or 4.0 (default: 3.0) + -1, --single-vcard Export all contacts to a single .vcf file + -m, --mapping PATH Path to JSON mapping file + -e, --encoding TEXT CSV file encoding (auto-detected if not set) --strict Exit on validation errors -v, --verbose Enable verbose output --version Show version and exit @@ -123,11 +170,14 @@ from csv2vcard.models import VCardVersion # Convert CSV to vCards files = csv2vcard( - csv_filename, # Path to CSV file + csv_filename, # Path to CSV file or directory csv_delimiter=",", # Field delimiter output_dir=None, # Output directory (default: ./export/) version=VCardVersion.V3_0, # vCard version strict=False, # Raise on validation errors + single_file=False, # Combine all contacts into one file + encoding=None, # File encoding (auto-detected) + mapping_file=None, # Path to JSON mapping file ) # Returns: List[Path] of created vCard files @@ -147,7 +197,11 @@ from csv2vcard.models import Contact, VCardVersion, VCardOutput contact = Contact( last_name="Doe", first_name="John", + middle_name="William", email="john@example.com", + phone="+1234567890", + birthday="1990-01-15", + nickname="Johnny", ) # Or from a dictionary @@ -158,10 +212,37 @@ VCardVersion.V3_0 # vCard 3.0 (RFC 2426) VCardVersion.V4_0 # vCard 4.0 (RFC 6350) ``` +## Supported vCard Fields + +| Field | Description | Example | +|-------|-------------|---------| +| `last_name` | Family name | Doe | +| `first_name` | Given name | John | +| `middle_name` | Middle name | William | +| `name_prefix` | Honorific prefix | Dr. | +| `name_suffix` | Honorific suffix | Jr. | +| `nickname` | Nickname | Johnny | +| `gender` | Gender (M/F/O/N/U) | M | +| `birthday` | Birth date (YYYY-MM-DD) | 1990-01-15 | +| `anniversary` | Anniversary date | 2015-06-20 | +| `phone` | Phone number | +1234567890 | +| `email` | Email address | john@example.com | +| `website` | Website URL | https://example.com | +| `org` | Organization | Acme Corp | +| `title` | Job title | Developer | +| `role` | Role/function | Team Lead | +| `street` | Street address | 123 Main St | +| `city` | City | New York | +| `region` | State/province | NY | +| `p_code` | Postal code | 10001 | +| `country` | Country | USA | +| `note` | Notes | Any additional info | + ## Requirements - Python 3.9 or higher - For CLI: `typer` (installed with `csv2vcard[cli]`) +- For encoding detection: `charset-normalizer` (installed with `csv2vcard[encoding]`) ## Development diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..83dbb79 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,15 @@ +coverage: + status: + project: + default: + target: 80 + threshold: 5 + patch: + default: + target: 80 + threshold: 5 + +comment: + layout: "reach, diff, flags, files" + behavior: default + require_changes: false diff --git a/csv2vcard/__init__.py b/csv2vcard/__init__.py index c4f6928..a578320 100644 --- a/csv2vcard/__init__.py +++ b/csv2vcard/__init__.py @@ -1,6 +1,6 @@ """csv2vcard - Convert CSV files to vCard format (3.0 and 4.0).""" -__version__ = "0.3.0" +__version__ = "0.4.0" # For backwards compatibility, users can still do: # from csv2vcard import csv2vcard diff --git a/csv2vcard/cli.py b/csv2vcard/cli.py index ab8b8d2..2c7725f 100644 --- a/csv2vcard/cli.py +++ b/csv2vcard/cli.py @@ -29,6 +29,7 @@ def _check_typer() -> None: from csv2vcard import __version__ from csv2vcard.csv2vcard import csv2vcard as csv2vcard_func from csv2vcard.csv2vcard import test_csv2vcard as test_csv2vcard_func + from csv2vcard.mapping import create_example_mapping from csv2vcard.models import VCardVersion app = typer.Typer( @@ -45,12 +46,11 @@ def version_callback(value: bool) -> None: @app.command() def convert( - csv_file: Annotated[ + source: Annotated[ Path, typer.Argument( - help="Path to the CSV file to convert", + help="Path to CSV file or directory containing CSV files", exists=True, - readable=True, ), ], delimiter: Annotated[ @@ -77,6 +77,30 @@ def convert( help="vCard version to generate: 3.0 or 4.0", ), ] = "3.0", + single_file: Annotated[ + bool, + typer.Option( + "--single-vcard", + "-1", + help="Export all contacts to a single .vcf file", + ), + ] = False, + mapping_file: Annotated[ + Path | None, + typer.Option( + "--mapping", + "-m", + help="Path to JSON mapping file for custom CSV column names", + ), + ] = None, + encoding: Annotated[ + str | None, + typer.Option( + "--encoding", + "-e", + help="CSV file encoding (auto-detected if not specified)", + ), + ] = None, strict: Annotated[ bool, typer.Option( @@ -103,10 +127,17 @@ def convert( ] = None, ) -> None: """ - Convert a CSV file to vCard files. + Convert CSV file(s) to vCard files. + + Examples: + + csv2vcard convert contacts.csv - Example: csv2vcard convert contacts.csv -d ";" -o ./vcards -V 4.0 + + csv2vcard convert ./csv_folder/ --single-vcard -o ./output + + csv2vcard convert data.csv -m mapping.json """ # Configure logging log_level = logging.DEBUG if verbose else logging.INFO @@ -124,11 +155,14 @@ def convert( try: files = csv2vcard_func( - csv_file, + source, delimiter, output_dir=output_dir, version=vc_version, strict=strict, + single_file=single_file, + encoding=encoding, + mapping_file=mapping_file, ) if files: typer.echo(f"Successfully created {len(files)} vCard file(s).") @@ -173,6 +207,17 @@ def test( test_csv2vcard_func(output_dir=output_dir, version=vc_version) typer.echo("Test vCard created successfully.") + @app.command(name="mapping") + def show_mapping() -> None: + """ + Show an example mapping file for custom CSV columns. + + The mapping file is a JSON file that maps vCard fields to + possible CSV column names. Copy this output to a .json file + and customize for your CSV format. + """ + typer.echo(create_example_mapping()) + else: # Fallback app when Typer is not installed def app() -> None: diff --git a/csv2vcard/create_vcard.py b/csv2vcard/create_vcard.py index 28e39d1..d6156db 100644 --- a/csv2vcard/create_vcard.py +++ b/csv2vcard/create_vcard.py @@ -34,7 +34,7 @@ def create_vcard( # Generate safe filename (prevents path traversal) filename = contact_obj.get_safe_filename() - name = f"{contact_obj.first_name} {contact_obj.last_name}".strip() + name = contact_obj.get_formatted_name() logger.debug(f"Created vCard {version.value} for {name}") @@ -69,6 +69,24 @@ def create_vcard_typed( ) +def _escape_vcard_value(value: str) -> str: + """ + Escape special characters in vCard values. + + Args: + value: Raw string value + + Returns: + Escaped string safe for vCard + """ + # Escape backslashes first, then other special chars + value = value.replace("\\", "\\\\") + value = value.replace(",", "\\,") + value = value.replace(";", "\\;") + value = value.replace("\n", "\\n") + return value + + def _create_vcard_3(contact: Contact) -> str: """ Generate vCard 3.0 format (RFC 2426). @@ -82,30 +100,80 @@ def _create_vcard_3(contact: Contact) -> str: lines = [ "BEGIN:VCARD", "VERSION:3.0", - f"N;CHARSET=UTF-8:{contact.last_name};{contact.first_name};;;", - f"FN;CHARSET=UTF-8:{contact.first_name} {contact.last_name}", ] + # N field: LastName;FirstName;MiddleName;Prefix;Suffix + n_parts = [ + _escape_vcard_value(contact.last_name), + _escape_vcard_value(contact.first_name), + _escape_vcard_value(contact.middle_name), + _escape_vcard_value(contact.name_prefix), + _escape_vcard_value(contact.name_suffix), + ] + lines.append(f"N;CHARSET=UTF-8:{';'.join(n_parts)}") + + # FN field: Formatted name + fn = contact.get_formatted_name() + lines.append(f"FN;CHARSET=UTF-8:{_escape_vcard_value(fn)}") + # Optional fields - only include if non-empty + if contact.nickname: + lines.append(f"NICKNAME;CHARSET=UTF-8:{_escape_vcard_value(contact.nickname)}") + + if contact.gender: + # vCard 3.0 doesn't have GENDER, use X-GENDER extension + lines.append(f"X-GENDER:{_escape_vcard_value(contact.gender)}") + + if contact.birthday: + # Format: YYYYMMDD or YYYY-MM-DD + bday = contact.birthday.replace("-", "") + lines.append(f"BDAY:{bday}") + + if contact.anniversary: + # vCard 3.0 uses X-ANNIVERSARY extension + anniv = contact.anniversary.replace("-", "") + lines.append(f"X-ANNIVERSARY:{anniv}") + if contact.title: - lines.append(f"TITLE;CHARSET=UTF-8:{contact.title}") + lines.append(f"TITLE;CHARSET=UTF-8:{_escape_vcard_value(contact.title)}") + + if contact.role: + lines.append(f"ROLE;CHARSET=UTF-8:{_escape_vcard_value(contact.role)}") + if contact.org: - lines.append(f"ORG;CHARSET=UTF-8:{contact.org}") + lines.append(f"ORG;CHARSET=UTF-8:{_escape_vcard_value(contact.org)}") + if contact.phone: lines.append(f"TEL;TYPE=WORK,VOICE:{contact.phone}") + if contact.email: lines.append(f"EMAIL;TYPE=WORK:{contact.email}") + if contact.website: lines.append(f"URL;TYPE=WORK:{contact.website}") # Address - only if at least one component is present - if any([contact.street, contact.city, contact.p_code, contact.country]): + if any([contact.street, contact.city, contact.region, contact.p_code, contact.country]): # ADR format: PO Box;Extended;Street;City;Region;PostalCode;Country - adr = ( - f"ADR;TYPE=WORK;CHARSET=UTF-8:;;{contact.street};" - f"{contact.city};;{contact.p_code};{contact.country}" - ) - lines.append(adr) + adr_parts = [ + "", # PO Box + "", # Extended address + _escape_vcard_value(contact.street), + _escape_vcard_value(contact.city), + _escape_vcard_value(contact.region), + _escape_vcard_value(contact.p_code), + _escape_vcard_value(contact.country), + ] + lines.append(f"ADR;TYPE=WORK;CHARSET=UTF-8:{';'.join(adr_parts)}") + + if contact.note: + lines.append(f"NOTE;CHARSET=UTF-8:{_escape_vcard_value(contact.note)}") + + # Add REV timestamp + lines.append(f"REV:{Contact.generate_rev()}") + + # Add UID + lines.append(f"UID:{contact.generate_uid()}") lines.append("END:VCARD") return "\n".join(lines) + "\n" @@ -124,27 +192,84 @@ def _create_vcard_4(contact: Contact) -> str: lines = [ "BEGIN:VCARD", "VERSION:4.0", - f"N:{contact.last_name};{contact.first_name};;;", - f"FN:{contact.first_name} {contact.last_name}", ] + # N field: LastName;FirstName;MiddleName;Prefix;Suffix + n_parts = [ + _escape_vcard_value(contact.last_name), + _escape_vcard_value(contact.first_name), + _escape_vcard_value(contact.middle_name), + _escape_vcard_value(contact.name_prefix), + _escape_vcard_value(contact.name_suffix), + ] + lines.append(f"N:{';'.join(n_parts)}") + + # FN field: Formatted name + fn = contact.get_formatted_name() + lines.append(f"FN:{_escape_vcard_value(fn)}") + # Optional fields - only include if non-empty # Note: CHARSET is not used in vCard 4.0 (UTF-8 is mandatory) + if contact.nickname: + lines.append(f"NICKNAME:{_escape_vcard_value(contact.nickname)}") + + if contact.gender: + # vCard 4.0 GENDER format: single letter (M/F/O/N/U) or ;text + gender = contact.gender.upper() + if gender in ("M", "F", "O", "N", "U"): + lines.append(f"GENDER:{gender}") + else: + # Use full text after semicolon + lines.append(f"GENDER:;{_escape_vcard_value(contact.gender)}") + + if contact.birthday: + # vCard 4.0 format: YYYYMMDD or --MMDD + bday = contact.birthday.replace("-", "") + lines.append(f"BDAY:{bday}") + + if contact.anniversary: + anniv = contact.anniversary.replace("-", "") + lines.append(f"ANNIVERSARY:{anniv}") + if contact.title: - lines.append(f"TITLE:{contact.title}") + lines.append(f"TITLE:{_escape_vcard_value(contact.title)}") + + if contact.role: + lines.append(f"ROLE:{_escape_vcard_value(contact.role)}") + if contact.org: - lines.append(f"ORG:{contact.org}") + lines.append(f"ORG:{_escape_vcard_value(contact.org)}") + if contact.phone: lines.append(f"TEL;TYPE=work,voice;VALUE=uri:tel:{contact.phone}") + if contact.email: lines.append(f"EMAIL;TYPE=work:{contact.email}") + if contact.website: lines.append(f"URL;TYPE=work:{contact.website}") # Address - if any([contact.street, contact.city, contact.p_code, contact.country]): - adr = f"ADR;TYPE=work:;;{contact.street};{contact.city};;{contact.p_code};{contact.country}" - lines.append(adr) + if any([contact.street, contact.city, contact.region, contact.p_code, contact.country]): + adr_parts = [ + "", # PO Box + "", # Extended address + _escape_vcard_value(contact.street), + _escape_vcard_value(contact.city), + _escape_vcard_value(contact.region), + _escape_vcard_value(contact.p_code), + _escape_vcard_value(contact.country), + ] + lines.append(f"ADR;TYPE=work:{';'.join(adr_parts)}") + + if contact.note: + lines.append(f"NOTE:{_escape_vcard_value(contact.note)}") + + # Add REV timestamp + lines.append(f"REV:{Contact.generate_rev()}") + + # Add UID + lines.append(f"UID:urn:uuid:{contact.generate_uid()}") lines.append("END:VCARD") return "\n".join(lines) + "\n" diff --git a/csv2vcard/csv2vcard.py b/csv2vcard/csv2vcard.py index 929a6b8..ab19fdf 100644 --- a/csv2vcard/csv2vcard.py +++ b/csv2vcard/csv2vcard.py @@ -7,9 +7,10 @@ from pathlib import Path from csv2vcard.create_vcard import create_vcard -from csv2vcard.export_vcard import ensure_export_dir, export_vcard +from csv2vcard.export_vcard import ensure_export_dir, export_vcard, export_vcards_combined +from csv2vcard.mapping import load_mapping from csv2vcard.models import VCardVersion -from csv2vcard.parse_csv import parse_csv +from csv2vcard.parse_csv import find_csv_files, parse_csv logger = logging.getLogger(__name__) @@ -21,17 +22,23 @@ def csv2vcard( output_dir: str | Path | None = None, version: VCardVersion = VCardVersion.V3_0, strict: bool = False, + single_file: bool = False, + encoding: str | None = None, + mapping_file: str | Path | None = None, csv_delimeter: str | None = None, # Legacy parameter name (deprecated) ) -> list[Path]: """ - Convert a CSV file to vCard files. + Convert a CSV file or directory to vCard files. Args: - csv_filename: Path to the CSV file + csv_filename: Path to the CSV file or directory containing CSV files csv_delimiter: Field delimiter character (default: ",") output_dir: Output directory (default: ./export/) version: vCard version to generate (default: 3.0) strict: Raise errors on validation issues (default: False) + single_file: Export all contacts to a single .vcf file (default: False) + encoding: File encoding (auto-detected if None) + mapping_file: Path to JSON mapping file (uses default if None) csv_delimeter: DEPRECATED - use csv_delimiter instead Returns: @@ -39,7 +46,7 @@ def csv2vcard( Example: >>> from csv2vcard import csv2vcard - >>> csv2vcard.csv2vcard("contacts.csv", ",") + >>> csv2vcard("contacts.csv", ",") [PosixPath('export/smith_john.vcf'), PosixPath('export/doe_jane.vcf')] """ # Handle deprecated parameter name @@ -53,20 +60,56 @@ def csv2vcard( logger.info(f"Converting CSV to vCard: {csv_filename}") + # Find all CSV files (supports both file and directory input) + try: + csv_files = find_csv_files(csv_filename) + except ValueError as e: + logger.error(str(e)) + if strict: + raise + return [] + + # Load mapping + mapping = load_mapping(mapping_file) + # Ensure export directory exists - ensure_export_dir(output_dir) + output_path = Path(output_dir) if output_dir else Path("export") + ensure_export_dir(output_path) + + # Parse all CSV files and generate vCards + all_vcards: list[dict[str, str]] = [] + for csv_file in csv_files: + contacts = parse_csv( + csv_file, + csv_delimiter, + strict=strict, + encoding=encoding, + mapping=mapping, + ) + for contact in contacts: + vcard = create_vcard(contact, version=version) + all_vcards.append(vcard) - # Parse CSV - contacts = parse_csv(csv_filename, csv_delimiter, strict=strict) + if not all_vcards: + logger.warning("No contacts found to convert") + return [] - # Generate and export vCards + # Export vCards created_files: list[Path] = [] - for contact in contacts: - vcard = create_vcard(contact, version=version) - output_path = export_vcard(vcard, output_dir) - created_files.append(output_path) - logger.info(f"Created {len(created_files)} vCard files") + if single_file: + # Export all to single file + combined_filename = "contacts.vcf" + combined_path = output_path / combined_filename + output_file = export_vcards_combined(all_vcards, combined_path) + created_files.append(output_file) + else: + # Export to separate files + for vcard in all_vcards: + output_file = export_vcard(vcard, output_path) + created_files.append(output_file) + + logger.info(f"Created {len(created_files)} vCard file(s)") return created_files @@ -91,8 +134,12 @@ def test_csv2vcard( "website": "https://www.linkedin.com/in/forrestgump", "street": "42 Plantation St.", "city": "Baytown", + "region": "LA", "p_code": "30314", "country": "United States of America", + "nickname": "Gumpy", + "birthday": "1944-06-06", + "note": "Life is like a box of chocolates.", } ensure_export_dir(output_dir) diff --git a/csv2vcard/export_vcard.py b/csv2vcard/export_vcard.py index d69de41..edbe6c0 100644 --- a/csv2vcard/export_vcard.py +++ b/csv2vcard/export_vcard.py @@ -99,6 +99,48 @@ def ensure_export_dir(output_dir: str | Path | None = None) -> Path: return export_path +def export_vcards_combined( + vcards: list[dict[str, str] | VCardOutput], + output_path: str | Path, +) -> Path: + """ + Export multiple vCards to a single .vcf file. + + Args: + vcards: List of vCard data (dicts or VCardOutput objects) + output_path: Full path to the output file (including filename) + + Returns: + Path to the created file + + Raises: + ExportError: If export fails + """ + output_file = Path(output_path) + + # Ensure parent directory exists + ensure_export_dir(output_file.parent) + + # Collect all vCard outputs + outputs: list[str] = [] + for vcard in vcards: + if isinstance(vcard, VCardOutput): + outputs.append(vcard.output) + else: + outputs.append(vcard["output"]) + + # Combine with newlines (each vCard already ends with newline) + combined = "".join(outputs) + + try: + output_file.write_text(combined, encoding="utf-8") + logger.info(f"Created combined vCard with {len(vcards)} contacts: {output_file}") + return output_file + except OSError as e: + logger.error(f"Failed to write combined vCard: {e}") + raise ExportError(f"Failed to export combined vCard: {e}") from e + + # Legacy function for backwards compatibility def check_export() -> None: """ diff --git a/csv2vcard/mapping.py b/csv2vcard/mapping.py new file mode 100644 index 0000000..34a1902 --- /dev/null +++ b/csv2vcard/mapping.py @@ -0,0 +1,150 @@ +"""CSV to vCard field mapping for csv2vcard.""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path + +from csv2vcard.models import ALL_FIELDS + +logger = logging.getLogger(__name__) + +# Default mapping: vCard field -> list of possible CSV column names +DEFAULT_MAPPING: dict[str, list[str]] = { + # Name components + "last_name": ["last_name", "lastname", "last", "surname", "family_name", "familyname"], + "first_name": ["first_name", "firstname", "first", "given_name", "givenname"], + "middle_name": ["middle_name", "middlename", "middle", "second_name"], + "name_prefix": ["name_prefix", "prefix", "title_prefix", "honorific_prefix", "salutation"], + "name_suffix": ["name_suffix", "suffix", "honorific_suffix", "generational"], + # Basic info + "nickname": ["nickname", "nick", "alias", "aka"], + "gender": ["gender", "sex"], + "birthday": ["birthday", "birthdate", "birth_date", "dob", "date_of_birth", "bday"], + "anniversary": ["anniversary", "wedding_anniversary", "wedding_date"], + # Contact + "phone": ["phone", "telephone", "tel", "mobile", "cell", "cellphone", "phone_number"], + "email": ["email", "e-mail", "email_address", "mail"], + "website": ["website", "url", "web", "homepage", "webpage", "site"], + # Organization + "org": ["org", "organization", "organisation", "company", "employer", "business"], + "title": ["title", "job_title", "jobtitle", "position"], + "role": ["role", "job_role", "function", "occupation"], + # Address + "street": ["street", "street_address", "address", "address1", "street1"], + "city": ["city", "locality", "town"], + "region": ["region", "state", "province", "county", "state_province"], + "p_code": ["p_code", "postal_code", "postalcode", "zip", "zipcode", "zip_code", "postcode"], + "country": ["country", "country_name", "nation"], + # Other + "note": ["note", "notes", "comment", "comments", "remarks", "description"], +} + + +def load_mapping(mapping_path: str | Path | None = None) -> dict[str, list[str]]: + """ + Load a field mapping from a JSON file or return the default mapping. + + Args: + mapping_path: Path to JSON mapping file, or None for default + + Returns: + Dictionary mapping vCard fields to lists of possible CSV column names + + Raises: + ValueError: If mapping file is invalid + """ + if mapping_path is None: + logger.debug("Using default field mapping") + return DEFAULT_MAPPING.copy() + + path = Path(mapping_path) + if not path.exists(): + raise ValueError(f"Mapping file not found: {path}") + + logger.info(f"Loading custom mapping from: {path}") + + try: + with open(path, encoding="utf-8") as f: + custom_mapping = json.load(f) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON in mapping file: {e}") from e + + # Validate mapping structure + if not isinstance(custom_mapping, dict): + raise ValueError("Mapping must be a JSON object") + + # Merge with defaults: custom overrides default + merged = DEFAULT_MAPPING.copy() + + for field, columns in custom_mapping.items(): + if field not in ALL_FIELDS: + logger.warning(f"Unknown field in mapping: {field}") + continue + + if isinstance(columns, str): + # Allow single string as shorthand + columns = [columns] + elif not isinstance(columns, list): + raise ValueError(f"Mapping for '{field}' must be a string or list of strings") + + merged[field] = columns + + return merged + + +def apply_mapping( + row: dict[str, str], + mapping: dict[str, list[str]], +) -> dict[str, str]: + """ + Apply field mapping to a CSV row, converting column names to vCard field names. + + Args: + row: Dictionary with CSV column names as keys + mapping: Field mapping (vCard field -> list of CSV column names) + + Returns: + Dictionary with vCard field names as keys + """ + result: dict[str, str] = {} + + # Normalize row keys for case-insensitive matching + normalized_row = {k.lower().strip(): v for k, v in row.items()} + + for vcard_field, csv_columns in mapping.items(): + for csv_col in csv_columns: + csv_col_lower = csv_col.lower().strip() + if csv_col_lower in normalized_row: + value = normalized_row[csv_col_lower] + if value: # Only set if non-empty + result[vcard_field] = value + break # First match wins + + return result + + +def create_example_mapping() -> str: + """ + Create an example mapping JSON for documentation purposes. + + Returns: + JSON string with example mapping + """ + example = { + "first_name": ["First Name", "Given Name", "FirstName"], + "last_name": ["Last Name", "Surname", "FamilyName"], + "email": ["Email", "E-Mail", "email_address"], + "phone": ["Phone", "Mobile", "Tel", "Telephone"], + "org": ["Company", "Organization", "Employer"], + "title": ["Job Title", "Position", "Title"], + "street": ["Address", "Street", "Street Address"], + "city": ["City", "Town", "Locality"], + "region": ["State", "Province", "Region"], + "p_code": ["Zip", "Postal Code", "ZIP Code", "Postcode"], + "country": ["Country", "Nation"], + "birthday": ["Birthday", "Birth Date", "DOB"], + "note": ["Notes", "Comments", "Remarks"], + } + return json.dumps(example, indent=2) diff --git a/csv2vcard/models.py b/csv2vcard/models.py index 40a885c..b1ba201 100644 --- a/csv2vcard/models.py +++ b/csv2vcard/models.py @@ -3,7 +3,9 @@ from __future__ import annotations import re +import uuid from dataclasses import dataclass, field +from datetime import datetime, timezone from enum import Enum @@ -17,19 +19,35 @@ class VCardVersion(Enum): # Required fields that must be present for a valid contact REQUIRED_FIELDS: frozenset[str] = frozenset({"last_name", "first_name"}) -# All supported contact fields +# All supported contact fields (v0.4.0 expanded) ALL_FIELDS: frozenset[str] = frozenset({ + # Name components "last_name", "first_name", - "title", - "org", + "middle_name", + "name_prefix", + "name_suffix", + # Basic info + "nickname", + "gender", + "birthday", + "anniversary", + # Contact "phone", "email", "website", + # Organization + "org", + "title", + "role", + # Address "street", "city", + "region", "p_code", "country", + # Other + "note", }) @@ -37,18 +55,39 @@ class VCardVersion(Enum): class Contact: """Represents a contact with validation and sanitization.""" - last_name: str - first_name: str - title: str = "" - org: str = "" + # Name components (N field) + last_name: str = "" + first_name: str = "" + middle_name: str = "" + name_prefix: str = "" # e.g., "Mr.", "Dr." + name_suffix: str = "" # e.g., "Jr.", "III" + + # Basic info + nickname: str = "" + gender: str = "" # M, F, O, N, U or full words + birthday: str = "" # YYYY-MM-DD or YYYYMMDD + anniversary: str = "" # YYYY-MM-DD or YYYYMMDD + + # Contact phone: str = "" email: str = "" website: str = "" + + # Organization + org: str = "" + title: str = "" + role: str = "" + + # Address (ADR field) street: str = "" city: str = "" + region: str = "" # state/province p_code: str = "" country: str = "" + # Other + note: str = "" + def __post_init__(self) -> None: """Validate and sanitize contact data after initialization.""" # Strip whitespace from all string fields @@ -69,17 +108,33 @@ def from_dict(cls, data: dict[str, str]) -> Contact: Contact instance """ return cls( + # Name components last_name=data.get("last_name", ""), first_name=data.get("first_name", ""), - title=data.get("title", ""), - org=data.get("org", ""), + middle_name=data.get("middle_name", ""), + name_prefix=data.get("name_prefix", ""), + name_suffix=data.get("name_suffix", ""), + # Basic info + nickname=data.get("nickname", ""), + gender=data.get("gender", ""), + birthday=data.get("birthday", ""), + anniversary=data.get("anniversary", ""), + # Contact phone=data.get("phone", ""), email=data.get("email", ""), website=data.get("website", ""), + # Organization + org=data.get("org", ""), + title=data.get("title", ""), + role=data.get("role", ""), + # Address street=data.get("street", ""), city=data.get("city", ""), + region=data.get("region", ""), p_code=data.get("p_code", ""), country=data.get("country", ""), + # Other + note=data.get("note", ""), ) def to_dict(self) -> dict[str, str]: @@ -90,17 +145,33 @@ def to_dict(self) -> dict[str, str]: Dictionary with all contact fields """ return { + # Name components "last_name": self.last_name, "first_name": self.first_name, - "title": self.title, - "org": self.org, + "middle_name": self.middle_name, + "name_prefix": self.name_prefix, + "name_suffix": self.name_suffix, + # Basic info + "nickname": self.nickname, + "gender": self.gender, + "birthday": self.birthday, + "anniversary": self.anniversary, + # Contact "phone": self.phone, "email": self.email, "website": self.website, + # Organization + "org": self.org, + "title": self.title, + "role": self.role, + # Address "street": self.street, "city": self.city, + "region": self.region, "p_code": self.p_code, "country": self.country, + # Other + "note": self.note, } def get_safe_filename(self) -> str: @@ -126,6 +197,45 @@ def get_safe_filename(self) -> str: return f"{safe_last}_{safe_first}.vcf" + def get_formatted_name(self) -> str: + """ + Get the formatted full name (FN field). + + Returns: + Formatted name string + """ + parts = [] + if self.name_prefix: + parts.append(self.name_prefix) + if self.first_name: + parts.append(self.first_name) + if self.middle_name: + parts.append(self.middle_name) + if self.last_name: + parts.append(self.last_name) + if self.name_suffix: + parts.append(self.name_suffix) + return " ".join(parts) or "Unknown" + + def generate_uid(self) -> str: + """ + Generate a unique identifier for this contact. + + Returns: + UUID string + """ + return str(uuid.uuid4()) + + @staticmethod + def generate_rev() -> str: + """ + Generate a revision timestamp (REV field). + + Returns: + ISO 8601 timestamp string + """ + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + @dataclass class VCardOutput: diff --git a/csv2vcard/parse_csv.py b/csv2vcard/parse_csv.py index 90a1e41..db45905 100644 --- a/csv2vcard/parse_csv.py +++ b/csv2vcard/parse_csv.py @@ -9,17 +9,81 @@ from pathlib import Path from csv2vcard.exceptions import ParseError, ValidationError +from csv2vcard.mapping import DEFAULT_MAPPING, apply_mapping, load_mapping from csv2vcard.models import Contact from csv2vcard.validators import validate_contact, validate_csv_file logger = logging.getLogger(__name__) +def detect_encoding(filepath: Path) -> str: + """ + Detect the encoding of a file. + + Uses charset_normalizer if available, otherwise falls back to utf-8. + + Args: + filepath: Path to the file + + Returns: + Detected encoding name + """ + try: + from charset_normalizer import from_path + + result = from_path(filepath).best() + if result: + encoding = result.encoding + logger.debug(f"Detected encoding: {encoding}") + return encoding + except ImportError: + logger.debug("charset_normalizer not installed, using utf-8") + except Exception as e: + logger.warning(f"Encoding detection failed: {e}, using utf-8") + + return "utf-8" + + +def find_csv_files(source: str | Path) -> list[Path]: + """ + Find CSV files from a file or directory path. + + Args: + source: Path to a CSV file or directory containing CSV files + + Returns: + List of CSV file paths + + Raises: + ValueError: If source doesn't exist or contains no CSV files + """ + source_path = Path(source) + + if not source_path.exists(): + raise ValueError(f"Source path does not exist: {source_path}") + + if source_path.is_file(): + if source_path.suffix.lower() != ".csv": + raise ValueError(f"Not a CSV file: {source_path}") + return [source_path] + + if source_path.is_dir(): + csv_files = sorted(source_path.glob("*.csv")) + if not csv_files: + raise ValueError(f"No CSV files found in directory: {source_path}") + logger.info(f"Found {len(csv_files)} CSV files in {source_path}") + return csv_files + + raise ValueError(f"Invalid source path: {source_path}") + + def parse_csv( csv_filename: str | Path, csv_delimiter: str = ",", *, strict: bool = False, + encoding: str | None = None, + mapping: dict[str, list[str]] | None = None, ) -> list[dict[str, str]]: """ Parse a CSV file and return a list of contact dictionaries. @@ -28,9 +92,11 @@ def parse_csv( csv_filename: Path to the CSV file csv_delimiter: Field delimiter character (default: ",") strict: If True, raise errors on validation issues + encoding: File encoding (auto-detected if None) + mapping: Field mapping (uses default if None) Returns: - List of contact dictionaries + List of contact dictionaries with vCard field names Raises: ParseError: If file cannot be parsed (only in strict mode) @@ -46,10 +112,18 @@ def parse_csv( logger.error(f"CSV validation failed: {filepath}") return [] - logger.info(f"Parsing CSV file: {filepath}") + # Detect encoding if not specified + if encoding is None: + encoding = detect_encoding(filepath) + + # Use default mapping if not provided + if mapping is None: + mapping = DEFAULT_MAPPING + + logger.info(f"Parsing CSV file: {filepath} (encoding: {encoding})") try: - with open(filepath, encoding="utf-8-sig", newline="") as f: + with open(filepath, encoding=encoding, newline="", errors="replace") as f: reader = csv.reader(f, delimiter=csv_delimiter) try: @@ -72,7 +146,12 @@ def parse_csv( ) continue - contact = dict(zip(header, row)) + # Create raw contact dict from CSV + raw_contact = dict(zip(header, row)) + + # Apply field mapping + contact = apply_mapping(raw_contact, mapping) + validation_warnings = validate_contact(contact, strict=strict) for warning in validation_warnings: logger.warning(f"Row {row_num}: {warning}") @@ -94,9 +173,55 @@ def parse_csv( return [] +def parse_csv_files( + source: str | Path, + csv_delimiter: str = ",", + *, + strict: bool = False, + encoding: str | None = None, + mapping_file: str | Path | None = None, +) -> list[dict[str, str]]: + """ + Parse one or more CSV files from a file or directory path. + + Args: + source: Path to a CSV file or directory containing CSV files + csv_delimiter: Field delimiter character (default: ",") + strict: If True, raise errors on validation issues + encoding: File encoding (auto-detected if None) + mapping_file: Path to JSON mapping file (uses default if None) + + Returns: + List of all contact dictionaries from all CSV files + + Raises: + ParseError: If parsing fails (only in strict mode) + ValueError: If source path is invalid + """ + csv_files = find_csv_files(source) + mapping = load_mapping(mapping_file) + + all_contacts: list[dict[str, str]] = [] + for csv_file in csv_files: + contacts = parse_csv( + csv_file, + csv_delimiter, + strict=strict, + encoding=encoding, + mapping=mapping, + ) + all_contacts.extend(contacts) + + logger.info(f"Total contacts parsed: {len(all_contacts)}") + return all_contacts + + def iter_contacts( csv_filename: str | Path, csv_delimiter: str = ",", + *, + encoding: str | None = None, + mapping: dict[str, list[str]] | None = None, ) -> Iterator[Contact]: """ Iterate over contacts in a CSV file (memory efficient). @@ -104,11 +229,15 @@ def iter_contacts( Args: csv_filename: Path to the CSV file csv_delimiter: Field delimiter character + encoding: File encoding (auto-detected if None) + mapping: Field mapping (uses default if None) Yields: Contact objects """ - for contact_dict in parse_csv(csv_filename, csv_delimiter): + for contact_dict in parse_csv( + csv_filename, csv_delimiter, encoding=encoding, mapping=mapping + ): yield Contact.from_dict(contact_dict) diff --git a/pyproject.toml b/pyproject.toml index 625d756..83f7571 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "csv2vcard" -version = "0.3.0" +version = "0.4.0" description = "A library for converting CSVs to vCards (vCard 3.0 and 4.0)" readme = "DESCRIPTION.md" license = "MIT" @@ -30,12 +30,15 @@ dependencies = [] [project.optional-dependencies] cli = ["typer>=0.9.0"] +encoding = ["charset-normalizer>=3.0.0"] +all = ["typer>=0.9.0", "charset-normalizer>=3.0.0"] dev = [ "pytest>=7.0", "pytest-cov>=4.0", "mypy>=1.0", "ruff>=0.1.0", "typer>=0.9.0", + "charset-normalizer>=3.0.0", ] [project.scripts] diff --git a/tests/conftest.py b/tests/conftest.py index ef23f3f..021c273 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,8 +3,8 @@ from __future__ import annotations import tempfile +from collections.abc import Generator from pathlib import Path -from typing import Dict, Generator import pytest @@ -17,7 +17,7 @@ def temp_dir() -> Generator[Path, None, None]: @pytest.fixture -def sample_contact() -> Dict[str, str]: +def sample_contact() -> dict[str, str]: """Return a sample contact dictionary with all fields populated.""" return { "last_name": "Gump", @@ -35,7 +35,7 @@ def sample_contact() -> Dict[str, str]: @pytest.fixture -def minimal_contact() -> Dict[str, str]: +def minimal_contact() -> dict[str, str]: """Return a minimal contact with only required fields.""" return { "last_name": "Doe", @@ -46,9 +46,9 @@ def minimal_contact() -> Dict[str, str]: @pytest.fixture def sample_csv(temp_dir: Path) -> Path: """Create a sample CSV file with valid contacts.""" - csv_content = """last_name,first_name,title,org,phone,email,website,street,city,p_code,country -Gump,Forrest,Shrimp Man,Bubba Gump Shrimp Co.,+1234567890,forrest@example.com,https://example.com,42 Plantation St.,Baytown,30314,USA -Doe,Jane,Developer,Tech Corp,+0987654321,jane@example.com,https://jane.dev,123 Main St.,New York,10001,USA + csv_content = """last_name,first_name,title,org,phone,email,street,city,p_code,country +Gump,Forrest,Shrimp Man,Bubba Gump,+1234567890,forrest@ex.com,42 Main,Baytown,30314,USA +Doe,Jane,Developer,Tech Corp,+0987654321,jane@ex.com,123 Main St.,NYC,10001,USA """ csv_path = temp_dir / "contacts.csv" csv_path.write_text(csv_content, encoding="utf-8") @@ -59,8 +59,8 @@ def sample_csv(temp_dir: Path) -> Path: def unicode_csv(temp_dir: Path) -> Path: """Create a CSV file with Unicode characters.""" csv_content = """last_name,first_name,title,org,phone,email,website,street,city,p_code,country -Mueller,Hans,Ingenieur,Firma GmbH,+49123456,hans@example.de,https://example.de,Strasse 1,Munchen,80331,Deutschland -Dupont,Marie,Directrice,Societe SA,+33123456,marie@example.fr,https://example.fr,Rue de la Paix,Paris,75001,France +Mueller,Hans,Ingenieur,Firma GmbH,+49123456,hans@ex.de,https://ex.de,Strasse 1,Munchen,80331,DE +Dupont,Marie,Directrice,Societe SA,+33123456,marie@ex.fr,https://ex.fr,Rue Paix,Paris,75001,FR """ csv_path = temp_dir / "unicode_contacts.csv" csv_path.write_text(csv_content, encoding="utf-8") diff --git a/tests/test_cli.py b/tests/test_cli.py index e49b601..01a2110 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8,9 +8,9 @@ # Only run CLI tests if typer is available typer = pytest.importorskip("typer") -from typer.testing import CliRunner +from typer.testing import CliRunner # noqa: E402 -from csv2vcard.cli import app +from csv2vcard.cli import app # noqa: E402 @pytest.fixture diff --git a/tests/test_create_vcard.py b/tests/test_create_vcard.py index 3d59125..a24f22c 100644 --- a/tests/test_create_vcard.py +++ b/tests/test_create_vcard.py @@ -2,10 +2,6 @@ from __future__ import annotations -from typing import Dict - -import pytest - from csv2vcard.create_vcard import ( _create_vcard_3, _create_vcard_4, @@ -18,7 +14,7 @@ class TestCreateVCard: """Test suite for vCard creation.""" - def test_create_vcard_returns_dict(self, sample_contact: Dict[str, str]) -> None: + def test_create_vcard_returns_dict(self, sample_contact: dict[str, str]) -> None: """Test that create_vcard returns a dictionary.""" result = create_vcard(sample_contact) @@ -27,7 +23,7 @@ def test_create_vcard_returns_dict(self, sample_contact: Dict[str, str]) -> None assert "output" in result assert "name" in result - def test_create_vcard_v3(self, sample_contact: Dict[str, str]) -> None: + def test_create_vcard_v3(self, sample_contact: dict[str, str]) -> None: """Test creating vCard 3.0.""" result = create_vcard(sample_contact, version=VCardVersion.V3_0) @@ -36,7 +32,7 @@ def test_create_vcard_v3(self, sample_contact: Dict[str, str]) -> None: assert "BEGIN:VCARD" in result["output"] assert "END:VCARD" in result["output"] - def test_create_vcard_v4(self, sample_contact: Dict[str, str]) -> None: + def test_create_vcard_v4(self, sample_contact: dict[str, str]) -> None: """Test creating vCard 4.0.""" result = create_vcard(sample_contact, version=VCardVersion.V4_0) @@ -44,12 +40,12 @@ def test_create_vcard_v4(self, sample_contact: Dict[str, str]) -> None: # v4.0 doesn't use CHARSET assert "CHARSET" not in result["output"] - def test_create_vcard_default_version(self, sample_contact: Dict[str, str]) -> None: + def test_create_vcard_default_version(self, sample_contact: dict[str, str]) -> None: """Test that default version is 3.0.""" result = create_vcard(sample_contact) assert "VERSION:3.0" in result["output"] - def test_create_vcard_minimal_contact(self, minimal_contact: Dict[str, str]) -> None: + def test_create_vcard_minimal_contact(self, minimal_contact: dict[str, str]) -> None: """Test creating vCard with minimal contact data.""" result = create_vcard(minimal_contact) @@ -60,7 +56,7 @@ def test_create_vcard_minimal_contact(self, minimal_contact: Dict[str, str]) -> assert "TITLE:" not in result["output"] or "TITLE:;" in result["output"] def test_create_vcard_accepts_contact_object( - self, sample_contact: Dict[str, str] + self, sample_contact: dict[str, str] ) -> None: """Test that create_vcard accepts Contact objects.""" contact = Contact.from_dict(sample_contact) @@ -69,7 +65,7 @@ def test_create_vcard_accepts_contact_object( assert result["filename"] == "gump_forrest.vcf" assert "Forrest" in result["output"] - def test_create_vcard_name_field(self, sample_contact: Dict[str, str]) -> None: + def test_create_vcard_name_field(self, sample_contact: dict[str, str]) -> None: """Test that name field is correctly formatted.""" result = create_vcard(sample_contact) assert result["name"] == "Forrest Gump" @@ -78,7 +74,7 @@ def test_create_vcard_name_field(self, sample_contact: Dict[str, str]) -> None: class TestCreateVCardTyped: """Test create_vcard_typed function.""" - def test_returns_vcard_output(self, sample_contact: Dict[str, str]) -> None: + def test_returns_vcard_output(self, sample_contact: dict[str, str]) -> None: """Test that create_vcard_typed returns VCardOutput.""" result = create_vcard_typed(sample_contact) @@ -86,7 +82,7 @@ def test_returns_vcard_output(self, sample_contact: Dict[str, str]) -> None: assert result.filename == "gump_forrest.vcf" assert result.version == VCardVersion.V3_0 - def test_with_v4(self, sample_contact: Dict[str, str]) -> None: + def test_with_v4(self, sample_contact: dict[str, str]) -> None: """Test create_vcard_typed with vCard 4.0.""" result = create_vcard_typed(sample_contact, version=VCardVersion.V4_0) @@ -97,14 +93,14 @@ def test_with_v4(self, sample_contact: Dict[str, str]) -> None: class TestVCard3Format: """Test vCard 3.0 format specifics.""" - def test_v3_has_charset(self, sample_contact: Dict[str, str]) -> None: + def test_v3_has_charset(self, sample_contact: dict[str, str]) -> None: """Test that vCard 3.0 includes CHARSET.""" contact = Contact.from_dict(sample_contact) output = _create_vcard_3(contact) assert "CHARSET=UTF-8" in output - def test_v3_name_format(self, sample_contact: Dict[str, str]) -> None: + def test_v3_name_format(self, sample_contact: dict[str, str]) -> None: """Test vCard 3.0 name format.""" contact = Contact.from_dict(sample_contact) output = _create_vcard_3(contact) @@ -112,14 +108,14 @@ def test_v3_name_format(self, sample_contact: Dict[str, str]) -> None: assert "N;CHARSET=UTF-8:Gump;Forrest;;;" in output assert "FN;CHARSET=UTF-8:Forrest Gump" in output - def test_v3_address_format(self, sample_contact: Dict[str, str]) -> None: + def test_v3_address_format(self, sample_contact: dict[str, str]) -> None: """Test vCard 3.0 address format.""" contact = Contact.from_dict(sample_contact) output = _create_vcard_3(contact) assert "ADR;TYPE=WORK;CHARSET=UTF-8:" in output - def test_v3_phone_format(self, sample_contact: Dict[str, str]) -> None: + def test_v3_phone_format(self, sample_contact: dict[str, str]) -> None: """Test vCard 3.0 phone format.""" contact = Contact.from_dict(sample_contact) output = _create_vcard_3(contact) @@ -127,7 +123,7 @@ def test_v3_phone_format(self, sample_contact: Dict[str, str]) -> None: assert "TEL;TYPE=WORK,VOICE:" in output def test_v3_optional_fields_omitted_when_empty( - self, minimal_contact: Dict[str, str] + self, minimal_contact: dict[str, str] ) -> None: """Test that empty optional fields are omitted.""" contact = Contact.from_dict(minimal_contact) @@ -140,14 +136,14 @@ def test_v3_optional_fields_omitted_when_empty( class TestVCard4Format: """Test vCard 4.0 format specifics.""" - def test_v4_no_charset(self, sample_contact: Dict[str, str]) -> None: + def test_v4_no_charset(self, sample_contact: dict[str, str]) -> None: """Test that vCard 4.0 doesn't include CHARSET.""" contact = Contact.from_dict(sample_contact) output = _create_vcard_4(contact) assert "CHARSET" not in output - def test_v4_name_format(self, sample_contact: Dict[str, str]) -> None: + def test_v4_name_format(self, sample_contact: dict[str, str]) -> None: """Test vCard 4.0 name format.""" contact = Contact.from_dict(sample_contact) output = _create_vcard_4(contact) @@ -155,14 +151,14 @@ def test_v4_name_format(self, sample_contact: Dict[str, str]) -> None: assert "N:Gump;Forrest;;;" in output assert "FN:Forrest Gump" in output - def test_v4_tel_format(self, sample_contact: Dict[str, str]) -> None: + def test_v4_tel_format(self, sample_contact: dict[str, str]) -> None: """Test vCard 4.0 telephone format.""" contact = Contact.from_dict(sample_contact) output = _create_vcard_4(contact) assert "TEL;TYPE=work,voice;VALUE=uri:tel:" in output - def test_v4_address_format(self, sample_contact: Dict[str, str]) -> None: + def test_v4_address_format(self, sample_contact: dict[str, str]) -> None: """Test vCard 4.0 address format.""" contact = Contact.from_dict(sample_contact) output = _create_vcard_4(contact) diff --git a/tests/test_csv2vcard.py b/tests/test_csv2vcard.py index 75067ed..1cd1e7a 100644 --- a/tests/test_csv2vcard.py +++ b/tests/test_csv2vcard.py @@ -88,8 +88,14 @@ def test_csv2vcard_deprecated_parameter( output_dir=output_dir, ) - assert len(w) == 1 - assert "deprecated" in str(w[0].message).lower() + # Filter for our specific deprecation warning + delimeter_warnings = [ + x for x in w + if issubclass(x.category, DeprecationWarning) + and "csv_delimeter" in str(x.message) + ] + assert len(delimeter_warnings) == 1 + assert "deprecated" in str(delimeter_warnings[0].message).lower() assert len(files) == 2 def test_csv2vcard_returns_empty_for_nonexistent_file( diff --git a/tests/test_mapping.py b/tests/test_mapping.py new file mode 100644 index 0000000..fc0fe06 --- /dev/null +++ b/tests/test_mapping.py @@ -0,0 +1,182 @@ +"""Tests for field mapping functionality.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from csv2vcard.mapping import ( + DEFAULT_MAPPING, + apply_mapping, + create_example_mapping, + load_mapping, +) + + +class TestLoadMapping: + """Test suite for load_mapping function.""" + + def test_load_default_mapping(self) -> None: + """Test loading default mapping when no file provided.""" + mapping = load_mapping(None) + + assert "first_name" in mapping + assert "last_name" in mapping + assert "email" in mapping + assert isinstance(mapping["first_name"], list) + + def test_load_custom_mapping(self, temp_dir: Path) -> None: + """Test loading custom mapping from JSON file.""" + custom_mapping = { + "first_name": ["Given Name", "FirstName"], + "email": ["EmailAddress"], + } + mapping_file = temp_dir / "mapping.json" + mapping_file.write_text(json.dumps(custom_mapping)) + + mapping = load_mapping(mapping_file) + + assert mapping["first_name"] == ["Given Name", "FirstName"] + assert mapping["email"] == ["EmailAddress"] + # Default fields should still be present + assert "last_name" in mapping + + def test_load_mapping_single_string(self, temp_dir: Path) -> None: + """Test that single string values are converted to lists.""" + custom_mapping = { + "first_name": "GivenName", # Single string + } + mapping_file = temp_dir / "mapping.json" + mapping_file.write_text(json.dumps(custom_mapping)) + + mapping = load_mapping(mapping_file) + + assert mapping["first_name"] == ["GivenName"] + + def test_load_mapping_nonexistent_file(self, temp_dir: Path) -> None: + """Test error when mapping file doesn't exist.""" + with pytest.raises(ValueError, match="not found"): + load_mapping(temp_dir / "nonexistent.json") + + def test_load_mapping_invalid_json(self, temp_dir: Path) -> None: + """Test error when mapping file contains invalid JSON.""" + mapping_file = temp_dir / "invalid.json" + mapping_file.write_text("not valid json") + + with pytest.raises(ValueError, match="Invalid JSON"): + load_mapping(mapping_file) + + def test_load_mapping_invalid_structure(self, temp_dir: Path) -> None: + """Test error when mapping is not an object.""" + mapping_file = temp_dir / "invalid.json" + mapping_file.write_text(json.dumps(["list", "not", "object"])) + + with pytest.raises(ValueError, match="must be a JSON object"): + load_mapping(mapping_file) + + +class TestApplyMapping: + """Test suite for apply_mapping function.""" + + def test_apply_default_mapping(self) -> None: + """Test applying default mapping to a row.""" + row = { + "first_name": "John", + "last_name": "Doe", + "email": "john@example.com", + } + result = apply_mapping(row, DEFAULT_MAPPING) + + assert result["first_name"] == "John" + assert result["last_name"] == "Doe" + assert result["email"] == "john@example.com" + + def test_apply_mapping_case_insensitive(self) -> None: + """Test that mapping is case-insensitive.""" + row = { + "First_Name": "John", + "LAST_NAME": "Doe", + "Email": "john@example.com", + } + result = apply_mapping(row, DEFAULT_MAPPING) + + assert result["first_name"] == "John" + assert result["last_name"] == "Doe" + assert result["email"] == "john@example.com" + + def test_apply_mapping_alternate_names(self) -> None: + """Test that alternate column names are recognized.""" + row = { + "firstname": "John", # Alternate spelling + "surname": "Doe", # Alternate name + "e-mail": "john@example.com", # With hyphen + } + result = apply_mapping(row, DEFAULT_MAPPING) + + assert result["first_name"] == "John" + assert result["last_name"] == "Doe" + assert result["email"] == "john@example.com" + + def test_apply_mapping_first_match_wins(self) -> None: + """Test that first matching column wins.""" + mapping = {"email": ["primary_email", "email"]} + row = { + "primary_email": "primary@example.com", + "email": "secondary@example.com", + } + result = apply_mapping(row, mapping) + + assert result["email"] == "primary@example.com" + + def test_apply_mapping_empty_values_skipped(self) -> None: + """Test that empty values are not included.""" + row = { + "first_name": "John", + "last_name": "", # Empty + } + result = apply_mapping(row, DEFAULT_MAPPING) + + assert result["first_name"] == "John" + assert "last_name" not in result + + +class TestCreateExampleMapping: + """Test suite for create_example_mapping function.""" + + def test_returns_valid_json(self) -> None: + """Test that example mapping is valid JSON.""" + example = create_example_mapping() + parsed = json.loads(example) + + assert isinstance(parsed, dict) + assert "first_name" in parsed + assert "last_name" in parsed + + def test_example_mapping_structure(self) -> None: + """Test structure of example mapping.""" + example = create_example_mapping() + parsed = json.loads(example) + + # All values should be lists + for _field, columns in parsed.items(): + assert isinstance(columns, list) + assert all(isinstance(c, str) for c in columns) + + +class TestDefaultMapping: + """Test suite for DEFAULT_MAPPING constant.""" + + def test_contains_all_fields(self) -> None: + """Test that default mapping covers all vCard fields.""" + from csv2vcard.models import ALL_FIELDS + + for field in ALL_FIELDS: + assert field in DEFAULT_MAPPING, f"Missing default mapping for: {field}" + + def test_all_values_are_lists(self) -> None: + """Test that all mapping values are lists.""" + for field, columns in DEFAULT_MAPPING.items(): + assert isinstance(columns, list), f"{field} mapping is not a list" + assert len(columns) > 0, f"{field} mapping is empty" diff --git a/tests/test_models.py b/tests/test_models.py index 42aaada..680f951 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -143,7 +143,17 @@ def test_required_fields(self) -> None: def test_all_fields(self) -> None: """Test ALL_FIELDS contains all expected fields.""" expected = { - "last_name", "first_name", "title", "org", "phone", - "email", "website", "street", "city", "p_code", "country", + # Name components + "last_name", "first_name", "middle_name", "name_prefix", "name_suffix", + # Basic info + "nickname", "gender", "birthday", "anniversary", + # Contact + "phone", "email", "website", + # Organization + "org", "title", "role", + # Address + "street", "city", "region", "p_code", "country", + # Other + "note", } - assert ALL_FIELDS == expected + assert expected == ALL_FIELDS From 95e4b3170032f3864671411b18d836d1d6e1525f Mon Sep 17 00:00:00 2001 From: tech4242 <5933291+tech4242@users.noreply.github.com> Date: Sun, 28 Dec 2025 20:11:24 +0100 Subject: [PATCH 2/4] fix: tests, linter --- setup.py | 1 + tests/test_cli.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 0ba12cf..0b8f6c8 100644 --- a/setup.py +++ b/setup.py @@ -2,6 +2,7 @@ # Configuration is now in pyproject.toml import os + from setuptools import setup with open(os.path.join(os.path.dirname(__file__), 'README.md')) as readme: diff --git a/tests/test_cli.py b/tests/test_cli.py index 01a2110..1451a88 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -181,4 +181,5 @@ def test_convert_help(self, runner: CliRunner) -> None: assert result.exit_code == 0 assert "CSV file" in result.stdout - assert "--delimiter" in result.stdout + # Check for "delimiter" without dashes due to ANSI escape codes in rich output + assert "delimiter" in result.stdout From 065f977eef6c14eacaf6d8bae2cf947af717ebc8 Mon Sep 17 00:00:00 2001 From: tech4242 <5933291+tech4242@users.noreply.github.com> Date: Sun, 28 Dec 2025 20:14:33 +0100 Subject: [PATCH 3/4] fix: python 3.9 support --- README.md | 1 + csv2vcard/cli.py | 11 ++++++----- pyproject.toml | 4 ++++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7c16c2c..e66a031 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![Downloads](https://static.pepy.tech/badge/csv2vcard)](https://pepy.tech/projects/csv2vcard) [![PyPI](https://img.shields.io/pypi/v/csv2vcard.svg)](https://pypi.org/project/csv2vcard/) +[![codecov](https://codecov.io/gh/tech4242/csv2vcard/graph/badge.svg?token=VUG1OXUH45)](https://codecov.io/gh/tech4242/csv2vcard) [![Python](https://img.shields.io/pypi/pyversions/csv2vcard.svg)](https://pypi.org/project/csv2vcard/) [![Typed](https://img.shields.io/badge/typed-py.typed-blue.svg)](https://peps.python.org/pep-0561/) [![Typer](https://img.shields.io/badge/CLI-Typer-2bbc8a.svg)](https://typer.tiangolo.com/) diff --git a/csv2vcard/cli.py b/csv2vcard/cli.py index 2c7725f..c378d22 100644 --- a/csv2vcard/cli.py +++ b/csv2vcard/cli.py @@ -5,6 +5,7 @@ import logging import sys from pathlib import Path +from typing import Optional # Check if typer is available try: @@ -62,7 +63,7 @@ def convert( ), ] = ",", output_dir: Annotated[ - Path | None, + Optional[Path], typer.Option( "--output", "-o", @@ -86,7 +87,7 @@ def convert( ), ] = False, mapping_file: Annotated[ - Path | None, + Optional[Path], typer.Option( "--mapping", "-m", @@ -94,7 +95,7 @@ def convert( ), ] = None, encoding: Annotated[ - str | None, + Optional[str], typer.Option( "--encoding", "-e", @@ -117,7 +118,7 @@ def convert( ), ] = False, version: Annotated[ - bool | None, + Optional[bool], typer.Option( "--version", callback=version_callback, @@ -177,7 +178,7 @@ def convert( @app.command() def test( output_dir: Annotated[ - Path | None, + Optional[Path], typer.Option( "--output", "-o", diff --git a/pyproject.toml b/pyproject.toml index 83f7571..ec8c371 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,10 @@ line-length = 100 [tool.ruff.lint] select = ["E", "F", "I", "N", "W", "UP", "B", "C4", "SIM"] +[tool.ruff.lint.per-file-ignores] +# cli.py uses Optional[X] for Python 3.9 compatibility with Typer runtime type evaluation +"csv2vcard/cli.py" = ["UP045"] + [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-v --cov=csv2vcard --cov-report=term-missing" From 703058bb42f2859ac95742d669ad66ec1361c83f Mon Sep 17 00:00:00 2001 From: tech4242 <5933291+tech4242@users.noreply.github.com> Date: Sun, 28 Dec 2025 20:18:16 +0100 Subject: [PATCH 4/4] fix: python 3.9 support --- csv2vcard/cli.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/csv2vcard/cli.py b/csv2vcard/cli.py index c378d22..3816ea1 100644 --- a/csv2vcard/cli.py +++ b/csv2vcard/cli.py @@ -1,7 +1,5 @@ """Command-line interface for csv2vcard.""" -from __future__ import annotations - import logging import sys from pathlib import Path