diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3c0a2ed --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: uv sync --group dev + + - name: Lint + run: uv run ruff check chatgpt_export_tool tests + + - name: Format check + run: uv run ruff format --check chatgpt_export_tool tests + + - name: Test + run: uv run pytest --cov=chatgpt_export_tool --cov-report=term-missing diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..d7becf6 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,28 @@ +name: Publish to PyPI + +on: + release: + types: [published] + +permissions: + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + environment: pypi + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install build tools + run: pip install build + + - name: Build package + run: python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/chatgpt_export_tool/commands/analyze.py b/chatgpt_export_tool/commands/analyze.py index 28a9590..b2d429c 100644 --- a/chatgpt_export_tool/commands/analyze.py +++ b/chatgpt_export_tool/commands/analyze.py @@ -1,7 +1,7 @@ """Analyze command for chatgpt_export_tool.""" import argparse -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from typing import Optional @@ -11,6 +11,7 @@ AnalyzeConfig, format_analysis_text, ) +from chatgpt_export_tool.core.config.models import DEFAULT_TIME_FORMAT from chatgpt_export_tool.core.file_utils import get_file_size from chatgpt_export_tool.core.parser import JSONParser from chatgpt_export_tool.core.utils import format_size @@ -54,11 +55,15 @@ def _execute(self) -> None: results = parser.analyze(verbose=self.logger.level <= 20) results["file_size"] = format_size(file_size) results["filepath"] = self.filepath - results["analysis_date"] = datetime.now().strftime("%H:%M %d-%m-%Y") + time_format = DEFAULT_TIME_FORMAT + results["analysis_date"] = datetime.now(tz=timezone.utc).strftime(time_format) output = format_analysis_text( results, - AnalyzeConfig(include_fields=self.include_fields), + AnalyzeConfig( + include_fields=self.include_fields, + time_format=time_format, + ), ) if self.output_file: diff --git a/chatgpt_export_tool/core/analysis_formatter.py b/chatgpt_export_tool/core/analysis_formatter.py index 86453eb..5dfd8e4 100644 --- a/chatgpt_export_tool/core/analysis_formatter.py +++ b/chatgpt_export_tool/core/analysis_formatter.py @@ -16,9 +16,11 @@ class AnalyzeConfig: Attributes: include_fields: Whether to include field coverage details. + time_format: strftime format for date/time display. """ include_fields: bool = False + time_format: str = "%H:%M %d-%m-%Y" def format_analysis_text( @@ -56,8 +58,10 @@ def format_analysis_text( lines.append(f"Total message nodes in mappings: {results['message_count']:,}") if results.get("min_date") is not None and results.get("max_date") is not None: - lines.append(f"From: {format_timestamp(results['min_date'])}") - lines.append(f"To: {format_timestamp(results['max_date'])}") + lines.append( + f"From: {format_timestamp(results['min_date'], config.time_format)}" + ) + lines.append(f"To: {format_timestamp(results['max_date'], config.time_format)}") lines.append("") diff --git a/chatgpt_export_tool/core/output/paths.py b/chatgpt_export_tool/core/output/paths.py index 65e7a0b..1fb5e21 100644 --- a/chatgpt_export_tool/core/output/paths.py +++ b/chatgpt_export_tool/core/output/paths.py @@ -94,4 +94,6 @@ def get_unique_filepath( candidate = filepath.with_name(f"{filepath.stem}_{suffix}{filepath.suffix}") if candidate not in used_paths and not candidate.exists(): return candidate - raise RuntimeError(f"Could not find unique path for {filepath} after 10000 attempts") + raise RuntimeError( + f"Could not find unique path for {filepath} after 10000 attempts" + ) diff --git a/chatgpt_export_tool/core/transcript/access.py b/chatgpt_export_tool/core/transcript/access.py index 4b117ab..f375955 100644 --- a/chatgpt_export_tool/core/transcript/access.py +++ b/chatgpt_export_tool/core/transcript/access.py @@ -1,6 +1,6 @@ """Shared read-only helpers for conversation structures.""" -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Iterator, Optional from .thread import ( @@ -86,7 +86,9 @@ def get_date_group_key(conversation: dict[str, Any]) -> Optional[str]: return None try: - return datetime.fromtimestamp(float(create_time)).strftime("%Y-%m-%d") + return datetime.fromtimestamp(float(create_time), tz=timezone.utc).strftime( + "%Y-%m-%d" + ) except (TypeError, ValueError, OSError): return None diff --git a/chatgpt_export_tool/core/utils.py b/chatgpt_export_tool/core/utils.py index 8240089..fd069d0 100644 --- a/chatgpt_export_tool/core/utils.py +++ b/chatgpt_export_tool/core/utils.py @@ -1,6 +1,6 @@ """Small shared formatting helpers.""" -from datetime import datetime +from datetime import datetime, timezone def format_size(size_bytes: int) -> str: @@ -21,14 +21,14 @@ def format_size(size_bytes: int) -> str: def format_timestamp(timestamp: float, time_format: str = "%H:%M %d-%m-%Y") -> str: - """Format a Unix timestamp to human-readable date string. + """Format a Unix timestamp to a UTC date string. Args: timestamp: Unix timestamp (seconds since epoch). May be float, int, or Decimal. + time_format: strftime format string. Returns: - Formatted date string. + Formatted UTC date string. """ - # Handle Decimal values from JSON parser - dt = datetime.fromtimestamp(float(timestamp)) + dt = datetime.fromtimestamp(float(timestamp), tz=timezone.utc) return dt.strftime(time_format)