diff --git a/README.md b/README.md index b2359f1..5b5e612 100644 --- a/README.md +++ b/README.md @@ -3,87 +3,88 @@ A command-line tool for managing Credential Engine platform resources. ## Requirements - + - Python 3.10 or later - `pip` + ## Installation - + > **Recommended:** install inside a virtual environment. This avoids file-permission errors, PATH conflicts, and "invalid distribution" warnings from previous installs. - + ### Option 1: Install from source with a virtual environment (recommended) - + **macOS / Linux:** - + ```bash git clone https://github.com/CredentialEngine/ce-cli.git cd ce-cli - + python3 -m venv .venv source .venv/bin/activate - + pip install -e . ``` - + **Windows (PowerShell):** - + ```powershell git clone https://github.com/CredentialEngine/ce-cli.git cd ce-cli - + python -m venv .venv .venv\Scripts\activate - + pip install -e . ``` - + When the venv is active you'll see `(.venv)` in your prompt, and `ce` is automatically on `PATH`. To leave the venv, run `deactivate`. To re-enter it next time, run the activate command above from the project folder. - + ### Option 2: Install from a release - + 1. Download the `.whl` from the [Releases page](https://github.com/CredentialEngine/ce-cli/releases). 2. Install it (inside a venv is still recommended): ```bash pip install ce_cli--py3-none-any.whl ``` - ### Upgrading - +### Upgrading + From a release `.whl`: - + ```bash pip install --upgrade ce_cli--py3-none-any.whl ``` - + From source: `git pull` and re-run `pip install -e .` inside your venv. - + ### Option 3: Install from source without a venv - + ```bash git clone https://github.com/CredentialEngine/ce-cli.git cd ce-cli pip install -e . ``` - + This puts the `ce` command on your `$PATH`. On Windows, see the [Windows PATH note](#windows-path-note) below if `ce` isn't found. - + ### Verify the installation - + ```bash ce --help ``` - + You should see the top-level command list. - + ### Windows PATH note - + When you install on Windows without a venv, pip often drops ce.exe into your user Scripts folder, which Windows doesn't put on PATH by default: - + ``` C:\Users\\AppData\Roaming\Python\Python310\Scripts ``` - + If `ce --help` fails after install, either use a venv (recommended) or add that folder to your User PATH: - + ```powershell [Environment]::SetEnvironmentVariable( "Path", @@ -91,10 +92,8 @@ If `ce --help` fails after install, either use a venv (recommended) or add that "User" ) ``` - -Then **close and reopen PowerShell** existing windows won't pick up the new PATH. - +Then **close and reopen PowerShell** existing windows won't pick up the new PATH. ## Quick start @@ -210,8 +209,8 @@ Verifies the JWT signatures and publishes each issuer to the IIR. Pass `--yes`/` | `VerificationMethod` | Yes | Full verification method ID (e.g., `did:key:z6Mk...#z6Mk...`) | | `Algorithm` | For did:key | `Ed25519`, `secp256k1`, `P-256`, or `X25519` | | `PrivateKey` | Yes | Multibase-encoded private key for signing | -| `ValidFrom` | No | Date when the issuer becomes valid (MM/DD/YYYY) | -| `ValidUntil` | No | Date when the issuer expires (MM/DD/YYYY) | +| `ValidFrom` | No | Date when the issuer becomes valid (YYYY-MM-DD) | +| `ValidUntil` | No | Date when the issuer expires (YYYY-MM-DD) | Example: diff --git a/ce/commands/iir.py b/ce/commands/iir.py index 60428a6..8e8e771 100644 --- a/ce/commands/iir.py +++ b/ce/commands/iir.py @@ -91,7 +91,7 @@ def bulk_sign(csv_path: str, output_path: str | None, errors_path: str | None) - \b Input CSV columns (optional): - ValidFrom, ValidUntil (format: MM/DD/YYYY) + ValidFrom, ValidUntil (format: YYYY-MM-DD) \b Produces two files: diff --git a/ce/iir/api/iir_client/models/pub_issuer_dto.py b/ce/iir/api/iir_client/models/pub_issuer_dto.py index 0be5f14..57f5541 100644 --- a/ce/iir/api/iir_client/models/pub_issuer_dto.py +++ b/ce/iir/api/iir_client/models/pub_issuer_dto.py @@ -36,6 +36,8 @@ class PubIssuerDTO: logo_uri (str | Unset): valid_from (datetime.datetime | Unset): valid_until (datetime.datetime | Unset): + date_valid_from (datetime.datetime | Unset): + date_valid_until (datetime.datetime | Unset): """ ctid: str | Unset = UNSET @@ -48,6 +50,8 @@ class PubIssuerDTO: logo_uri: str | Unset = UNSET valid_from: datetime.datetime | Unset = UNSET valid_until: datetime.datetime | Unset = UNSET + date_valid_from: datetime.datetime | Unset = UNSET + date_valid_until: datetime.datetime | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) @@ -79,6 +83,14 @@ def to_dict(self) -> dict[str, Any]: if not isinstance(self.valid_until, Unset): valid_until = self.valid_until.isoformat() + date_valid_from: str | Unset = UNSET + if not isinstance(self.date_valid_from, Unset): + date_valid_from = self.date_valid_from.isoformat() + + date_valid_until: str | Unset = UNSET + if not isinstance(self.date_valid_until, Unset): + date_valid_until = self.date_valid_until.isoformat() + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) @@ -104,6 +116,10 @@ def to_dict(self) -> dict[str, Any]: field_dict["ValidFrom"] = valid_from if valid_until is not UNSET: field_dict["ValidUntil"] = valid_until + if date_valid_from is not UNSET: + field_dict["DateValidFrom"] = date_valid_from + if date_valid_until is not UNSET: + field_dict["DateValidUntil"] = date_valid_until return field_dict @@ -148,6 +164,26 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + _date_valid_from = d.pop("DateValidFrom", UNSET) + date_valid_from: datetime.datetime | Unset + if isinstance(_date_valid_from, Unset): + date_valid_from = UNSET + else: + date_valid_from = isoparse(_date_valid_from) + + + + + _date_valid_until = d.pop("DateValidUntil", UNSET) + date_valid_until: datetime.datetime | Unset + if isinstance(_date_valid_until, Unset): + date_valid_until = UNSET + else: + date_valid_until = isoparse(_date_valid_until) + + + + pub_issuer_dto = cls( ctid=ctid, did=did, @@ -159,6 +195,8 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: logo_uri=logo_uri, valid_from=valid_from, valid_until=valid_until, + date_valid_from=date_valid_from, + date_valid_until=date_valid_until, ) diff --git a/ce/iir/did_ops.py b/ce/iir/did_ops.py index 981f716..55cb46f 100644 --- a/ce/iir/did_ops.py +++ b/ce/iir/did_ops.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 +from datetime import timezone import json import re from functools import lru_cache @@ -88,12 +89,12 @@ def validate_date(value: str, field_name: str) -> tuple[bool, str, str]: return True, "", "" from datetime import datetime, timezone try: - parsed = datetime.strptime(value, "%m/%d/%Y") - utc_dt = parsed.replace(tzinfo=timezone.utc) + parsed = datetime.strptime(value, "%Y-%m-%d") + utc_dt = parsed.replace(hour=0, minute=0, second=0, tzinfo=timezone.utc) iso_value = utc_dt.strftime("%Y-%m-%dT%H:%M:%SZ") return True, "", iso_value except ValueError: - return False, f"{field_name} must be in MM/DD/YYYY format, got '{value}'", "" + return False, f"{field_name} must be in YYYY-MM-DD format, got '{value}'", "" def validate_date_range(valid_from: str, valid_until: str) -> tuple[bool, str]: @@ -433,16 +434,18 @@ def call_submit_to_iir( ) -> tuple[bool, str]: from datetime import datetime - def _to_dt_or_unset(value: str): + def _to_iso_z(value: str) -> str: if not value: - return UNSET + return "" try: - return datetime.fromisoformat(value.replace("Z", "+00:00")) + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError: try: - return datetime.strptime(value, "%Y-%m-%d") + dt = datetime.strptime(value, "%Y-%m-%d") except ValueError: - return UNSET + return "" + local_midnight = datetime(dt.year, dt.month, dt.day).astimezone() + return local_midnight.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") try: client = _client(token, publisher_base, ssl_verify) @@ -456,10 +459,15 @@ def _to_dt_or_unset(value: str): subject_web_page=subject_web_page, logo_base_64=logo_base64 if logo_base64 else UNSET, logo_uri=logo_uri if logo_uri else UNSET, - valid_from=_to_dt_or_unset(valid_from), - valid_until=_to_dt_or_unset(valid_until), ) + df = _to_iso_z(valid_from) + du = _to_iso_z(valid_until) + if df: + body.additional_properties["DateValidFrom"] = df + if du: + body.additional_properties["DateValidUntil"] = du + response = submit_to_iir_sync(client=client, body=body) if response.status_code == 409: diff --git a/ce/iir/openapi.json b/ce/iir/openapi.json index 448411d..4c9d056 100644 --- a/ce/iir/openapi.json +++ b/ce/iir/openapi.json @@ -977,6 +977,14 @@ "ValidUntil": { "format": "date-time", "type": "string" + }, + "DateValidFrom": { + "format": "date-time", + "type": "string" + }, + "DateValidUntil": { + "format": "date-time", + "type": "string" } } } diff --git a/ce/iir/swagger.json b/ce/iir/swagger.json index 546034d..a2e9fff 100644 --- a/ce/iir/swagger.json +++ b/ce/iir/swagger.json @@ -706,6 +706,14 @@ "ValidUntil": { "format": "date-time", "type": "string" + }, + "DateValidFrom": { + "format": "date-time", + "type": "string" + }, + "DateValidUntil": { + "format": "date-time", + "type": "string" } } } diff --git a/tests/test_did_ops_unit.py b/tests/test_did_ops_unit.py index 60eff99..1e56428 100644 --- a/tests/test_did_ops_unit.py +++ b/tests/test_did_ops_unit.py @@ -7,7 +7,7 @@ from __future__ import annotations import base64 import json -from datetime import datetime, timezone +from datetime import date, datetime, timezone from types import SimpleNamespace from unittest.mock import patch @@ -184,19 +184,19 @@ def test_empty_value_is_treated_as_optional(self): assert ok and err == "" and iso == "" def test_valid_date_converts_to_iso8601(self): - ok, err, iso = validate_date("01/15/2024", "ValidFrom") + ok, err, iso = validate_date("2024-01-15", "ValidFrom") assert ok and err == "" assert iso == "2024-01-15T00:00:00Z" - def test_iso_format_input_is_rejected(self): - ok, err, _ = validate_date("2024-01-15", "ValidFrom") + def test_mm_dd_yyyy_input_is_rejected(self): + ok, err, _ = validate_date("01/15/2024", "ValidFrom") assert not ok - assert "MM/DD/YYYY" in err + assert "YYYY-MM-DD" in err def test_impossible_date_fails(self): - ok, err, _ = validate_date("13/99/2024", "ValidFrom") + ok, err, _ = validate_date("2024-99-99", "ValidFrom") assert not ok - assert "MM/DD/YYYY" in err + assert "YYYY-MM-DD" in err class TestValidateDateRange: @@ -206,33 +206,33 @@ def test_both_empty_is_ok(self): assert ok and err == "" def test_only_from_provided_fails(self): - ok, err = validate_date_range("01/01/2024", "") + ok, err = validate_date_range("2024-01-01", "") assert not ok assert "both" in err.lower() def test_only_until_provided_fails(self): - ok, err = validate_date_range("", "01/01/2024") + ok, err = validate_date_range("", "2024-01-01") assert not ok assert "both" in err.lower() def test_until_before_from_fails(self): - ok, err = validate_date_range("06/01/2024", "01/01/2024") + ok, err = validate_date_range("2024-06-01", "2024-01-01") assert not ok assert "after" in err.lower() def test_until_equal_to_from_fails(self): - ok, err = validate_date_range("01/01/2024", "01/01/2024") + ok, err = validate_date_range("2024-01-01", "2024-01-01") assert not ok assert "after" in err.lower() def test_until_after_from_passes(self): - ok, err = validate_date_range("01/01/2024", "01/02/2024") + ok, err = validate_date_range("2024-01-01", "2024-01-02") assert ok and err == "" def test_invalid_format_propagates_error(self): - ok, err = validate_date_range("not-a-date", "01/01/2024") + ok, err = validate_date_range("not-a-date", "2024-01-01") assert not ok - assert "MM/DD/YYYY" in err + assert "YYYY-MM-DD" in err class TestClassifyDid: @@ -714,6 +714,12 @@ def _base_args(self): ACCESS_TOKEN, PUBLISHER_BASE, False, ) + @staticmethod + def _local_date_of_z(z: str) -> date: + """Parse a 'YYYY-MM-DDT00:00:00Z' string and return its date in local time.""" + dt = datetime.strptime(z, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + return dt.astimezone().date() + def test_success(self): with patch("ce.iir.did_ops.submit_to_iir_sync") as mock_sync: mock_sync.return_value = _fake_response(200, None) @@ -726,7 +732,7 @@ def test_409_means_did_already_registered(self): ok, err = call_submit_to_iir(*self._base_args()) assert not ok and err == "Did already exists" - def test_iso_strings_become_datetime_objects_on_dto(self): + def test_iso_strings_become_z_strings_in_additional_properties(self): with patch("ce.iir.did_ops.submit_to_iir_sync") as mock_sync: mock_sync.return_value = _fake_response(200, None) call_submit_to_iir( @@ -736,10 +742,11 @@ def test_iso_strings_become_datetime_objects_on_dto(self): ) _, kwargs = mock_sync.call_args body = kwargs["body"] - assert isinstance(body.valid_from, datetime) - assert isinstance(body.valid_until, datetime) - assert body.valid_from == datetime(2024, 1, 1, tzinfo=timezone.utc) - assert body.valid_until == datetime(2025, 1, 1, tzinfo=timezone.utc) + df = body.additional_properties["DateValidFrom"] + du = body.additional_properties["DateValidUntil"] + assert df.endswith("Z") and du.endswith("Z") + assert self._local_date_of_z(df) == date(2024, 1, 1) + assert self._local_date_of_z(du) == date(2025, 1, 1) def test_yyyy_mm_dd_dates_also_parse(self): with patch("ce.iir.did_ops.submit_to_iir_sync") as mock_sync: @@ -749,10 +756,21 @@ def test_yyyy_mm_dd_dates_also_parse(self): ) _, kwargs = mock_sync.call_args body = kwargs["body"] - assert isinstance(body.valid_from, datetime) - assert body.valid_from.year == 2024 and body.valid_from.month == 6 + df = body.additional_properties["DateValidFrom"] + assert self._local_date_of_z(df) == date(2024, 6, 15) - def test_empty_optional_fields_are_unset_not_none(self): + def test_dto_to_dict_serializes_dates_under_capitalized_keys(self): + with patch("ce.iir.did_ops.submit_to_iir_sync") as mock_sync: + mock_sync.return_value = _fake_response(200, None) + call_submit_to_iir( + *self._base_args(), valid_from="2024-06-15", valid_until="2025-06-15" + ) + _, kwargs = mock_sync.call_args + d = kwargs["body"].to_dict() + assert d["DateValidFrom"].endswith("Z") + assert d["DateValidUntil"].endswith("Z") + + def test_empty_optional_fields_are_absent(self): with patch("ce.iir.did_ops.submit_to_iir_sync") as mock_sync: mock_sync.return_value = _fake_response(200, None) call_submit_to_iir(*self._base_args()) @@ -762,20 +780,23 @@ def test_empty_optional_fields_are_unset_not_none(self): assert body.logo_base_64 is UNSET assert body.valid_from is UNSET assert body.valid_until is UNSET + assert "DateValidFrom" not in body.additional_properties + assert "DateValidUntil" not in body.additional_properties def test_dto_to_dict_does_not_crash_on_empty_optionals(self): with patch("ce.iir.did_ops.submit_to_iir_sync") as mock_sync: mock_sync.return_value = _fake_response(200, None) call_submit_to_iir(*self._base_args()) _, kwargs = mock_sync.call_args - body = kwargs["body"] - d = body.to_dict() + d = kwargs["body"].to_dict() assert "ValidFrom" not in d assert "ValidUntil" not in d + assert "DateValidFrom" not in d + assert "DateValidUntil" not in d assert "LogoUri" not in d assert "LogoBase64" not in d - def test_invalid_date_string_falls_back_to_unset(self): + def test_invalid_date_string_is_omitted(self): with patch("ce.iir.did_ops.submit_to_iir_sync") as mock_sync: mock_sync.return_value = _fake_response(200, None) call_submit_to_iir( @@ -783,8 +804,8 @@ def test_invalid_date_string_falls_back_to_unset(self): ) _, kwargs = mock_sync.call_args body = kwargs["body"] - assert body.valid_from is UNSET - + assert "DateValidFrom" not in body.additional_properties + assert "DateValidUntil" not in body.additional_properties class TestHelpers: def test_parse_uvarint_roundtrip_example(self):