diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e0a17c9 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +PASSKIT_PASSPHRASE=your_passphrase +PASSKIT_ADDRESS=grpc.pub1.passkit.io +PASSKIT_PORT=443 +PASSKIT_ROOT_CERT=certs/ca-chain.pem +PASSKIT_PRIVATE_KEY=certs/key.pem +PASSKIT_CERTIFICATE=certs/certificate.pem +PASSKIT_CONNECTION_MODE=pool +PASSKIT_POOL_SIZE=5 +PASSKIT_KEEP_ASSETS=false +PASSKIT_RECIPIENT_EMAIL= +PASSKIT_APPLE_CERTIFICATE= +PASSKIT_FLIGHT_CARRIER=YY +PASSKIT_FLIGHT_ORIGIN=YY4 +PASSKIT_FLIGHT_DESTINATION=ADP diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a478f1b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: "/" + schedule: + interval: monthly + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: monthly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ecc0c04 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - run: python -m pip install --upgrade pip + - run: python -m pip install -e ".[dev]" + - run: python -m unittest discover -s tests -v + - run: python -m ruff check . + - run: python -m ruff format --check . diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml new file mode 100644 index 0000000..4af0cb3 --- /dev/null +++ b/.github/workflows/gitleaks.yml @@ -0,0 +1,21 @@ +name: Gitleaks + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: gitleaks/gitleaks-action@v3 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} diff --git a/.gitignore b/.gitignore index 2dd7372..6c26f2e 100644 --- a/.gitignore +++ b/.gitignore @@ -103,6 +103,8 @@ celerybeat.pid # Environments .env +.env.* +!.env.example .venv env/ venv/ @@ -128,4 +130,8 @@ dmypy.json # Pyre type checker .pyre/ -./certs +certs/ +.DS_Store +.idea/ +.vscode/ +.ruff_cache/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e59105d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) PassKit + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index d3ab3ab..d4eadf2 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,247 @@ -PassKit Python Quickstart -======================= - -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Version](https://badge.fury.io/py/passkit-python-grpc-sdk.svg)](https://pypi.org/project/passkit-python-grpc-sdk/) - -### Overview - -This quickstart aims to help get Python developers up and running with the PassKit SDK as quickly as possible. - -### Prerequisites - -You will need the following: - -- A PassKit account (signup for free at https://app.passkit.com) -- Your PassKit SDK Credentials (available from the https://app.passkit.com/app/account/developer-tools) -- Python 3.7 or above from https://www.oracle.com/java/technologies/downloads/ (https://docs.oracle.com/en/java/javase/18/install/overview-jdk-installation.html - guide on how to download) -- Gradle Build Tool from https://gradle.org/install/ with guide on how to install -- Apple wallet certificate id (for flights only, https://app.passkit.com/app/account/certificates) - ![ScreenShot](images/certificate.png) - - The following Python packages: - - `passkit-python-grpc-sdk` - - `protobuf>=5.26` - - `grpcio>=1.60` - - `grpcio-tools>=1.60` - - `protoc-gen-openapiv2` - - `googleapis-common-protos` - -You can install all required Python packages with: - -`pip install passkit-python-grpc-sdk "protobuf>=5.26" "grpcio>=1.60" "grpcio-tools>=1.60" protoc-gen-openapiv2 googleapis-common-protos` - - -### Configuration - -1. Download or clone this quickstart repository, create a folder `certs` in the resources folder of the repository and add the following three PassKit credential files: - - certificate.pem - - ca-chain.pem - - key.pem - - You can disregard the key-java.pem credentials file as it is not compatible with Python. -2. Use `pip install passkit-python-grpc-sdk` to download the latest sdk from python or use `pip install passkit-python-grpc-sdk --upgrade` to upgrade to the latest. - -### Membership Cards -In the membership folder the methods there are: -- create-program.py - takes a new program name and creates a new program -- create-tier.py - takes the programId of the program just created in the above program, creates a new template (based of default template), creates a tier, and links this tier to the program -- enrol-member.py - takes programId and tierId created by the above methods, and memberDetails, creates a new member record, and sends a welcome email to deliver membership card url -- update-member.py - takes memberId and memberDetails, and updates existing member record -- check-in-member.py - takes memberId and location details and checks in the selected member -- check-out-member.py - takes memberId and location details and checks out the selected member -- earn-points.py - takes a programId of an existing program and memberId of existing member to add points to chosen member -- burn-points.py - takes a programId of an existing program and memberId of existing member to use points from a chosen member -- delete-member.py - takes programId, tierId, memberId and memberDetails, deletes an existing member record - -Run individually using `python3 -m membership.chosenMethod` - -### Coupons -In the coupons folder the methods are: -- create-campaign.py - takes a new campaign name and creates a new campaign -- create-offer.py - takes a campaignId of the campaign you just created and creates a new template (based of default template), creates an offer, and links this offer to the campaign -- create-coupon.py - takes campaignId and offerId created by the above methods, and couponDetails, creates a new coupon record, and sends a welcome email to deliver coupon card url -- list-coupons.py - takes campaignId and returns list of coupon records under that campaign -- update-coupon.py - takes a campaignId of an existing campaign and couponId of existing coupon to update that coupon -- redeem-coupon.py - takes a campaignId of an existing campaign and couponId of existing coupon to redeem that coupon -- void-coupon.py - takes the couponId, offerId and campaignId to void an existing coupon - -Run individually using `python3 -m coupons.chosenMethod` - -### Boarding Passes -#### Issue A Boarding Pass. -In the flights folder the methods are: -- create-template.py - creates the pass template for flights and boarding passes -- create-carrier.py - takes a new carrier code and creates a new carrier -- create-airport.py - takes a new airport code and creates a new airport. -- create-flight.py - takes templateId , from previous method, to use as base template and uses a carrier code, created from previous method, and creates a new flight -- create-flight-designator.py - creates flight designator using flight code -- create-boarding-pass.py - takes templateId, from previous method, and customer details creates a new boarding pass, and sends a welcome email to deliver boarding pass url -- delete-flight.py - takes an existing flight number as well as other details and deletes the flight associated with it -- delete-flight-designator.py - takes an existing flight designation and deletes the flight designator associated with it -- delete-airports.py - takes an existing airport code and deletes the airport associated with it -- delete-carrier.py - takes an existing carrier code and deletes the carrier associated with it - -Run individually using `python3 -m flights.chosenMethod` - -To run all methods use `python3 quickstart-all.py` - - -## Documentation -* [PassKit Membership Official Documentation](https://docs.passkit.io/protocols/member) -* [PassKit Coupons Official Documentation](https://docs.passkit.io/protocols/coupon) -* [PassKit Boarding Passes Official Documentation](https://docs.passkit.io/protocols/boarding) -* [PassKit Events Official Documentation](https://docs.passkit.io/protocols/event-tickets/) +# PassKit Python Quickstart +[![CI](https://github.com/PassKit/passkit-python-quickstart/actions/workflows/ci.yml/badge.svg)](https://github.com/PassKit/passkit-python-quickstart/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/passkit-python-grpc-sdk.svg)](https://pypi.org/project/passkit-python-grpc-sdk/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +Create a working PassKit membership card, coupon, event ticket, or flight +boarding pass with the official Python SDK. Each guided example runs with one +command, prints the resulting wallet pass URL, and removes the test resources +it created. + +## Quick start + +You need Python 3.9 or newer, a free +[PassKit account](https://app.passkit.com/signup), and PassKit SDK credentials. + +### 1. Download and install + +```bash +git clone https://github.com/PassKit/passkit-python-quickstart.git +cd passkit-python-quickstart +python3 -m venv .venv +source .venv/bin/activate +python -m pip install -e . +``` + +On Windows PowerShell, activate the environment with: + +```powershell +.venv\Scripts\Activate.ps1 +``` + +### 2. Get your PassKit credentials + +1. Sign in to [PassKit](https://app.passkit.com). +2. Open **Developer Tools** from the account menu. +3. Under **Account Credentials**, select **SDK Credentials**. +4. Choose a strong password when prompted. +5. Download the three files sent to your registered email address: + + - `certificate.pem` + - `key.pem` + - `ca-chain.pem` + +The password encrypts your private key; it is not your PassKit account +password. Generating another credential set invalidates the existing one. + +### 3. Configure the quickstart + +Create `certs/`, copy in all three credential files, and create `.env`: + +```bash +mkdir -p certs +cp /path/to/downloads/certificate.pem certs/ +cp /path/to/downloads/key.pem certs/ +cp /path/to/downloads/ca-chain.pem certs/ +cp .env.example .env +``` + +Open `.env` and set `PASSKIT_PASSPHRASE` to the SDK credential password. +Check **Developer Tools → API Region** and use: + +- `grpc.pub1.passkit.io` for Europe +- `grpc.pub2.passkit.io` for the USA + +The files under `certs/` and your `.env` are ignored by Git. Never commit or +share them. + +### 4. Run an example + +```bash +python main.py membership +python main.py coupons +python main.py event-tickets +python main.py flights +``` + +`loyalty` is an alias for `membership`, and `tickets` is an alias for +`event-tickets`. + +A successful run prints output similar to: + +```text +Created resources: + bronzePassId: https://pub1.pskt.io/4MEIqDFudziP4ZFKx5osw3 +Cleaning up generated resources... +``` + +Open the URL on a phone to add the pass to Apple Wallet or Google Wallet. On a +desktop, the PassKit page displays a QR code you can scan. + +## What each workflow demonstrates + +| Workflow | Included operations | +| --- | --- | +| Membership | Images, two templates, program and tiers, member enrolment, update, ID and external-ID lookup, check-in/out, earn and burn points, list, count, event history, and cleanup | +| Coupons | Images, before/after templates, campaign and offers, issue, update, get, list, count, redeem, void, and cleanup | +| Event tickets | Images, template, production, venue, future-dated event, ticket type, issue, update, lookup by ID/ticket/order number, list, count, validate, redeem, and cleanup | +| Flights | Images, template, carrier and airport create-or-reuse, future-dated flight and designator, lookup, boarding-pass issue and lookup, and ordered cleanup | + +The focused implementations are under `quickstarts/workflows/`. Shared image, +template, cleanup, configuration, and connection logic is kept under +`quickstarts/`. + +Each product also provides focused reusable calls through its own `methods.py`: +[`membership`](membership/README.md), [`coupons`](coupons/README.md), +[`event_tickets`](event_tickets/README.md), and [`flights`](flights/README.md). +The original individual scripts remain as compatible entry points. The guided +workflows are self-contained and use the maintained implementations under +`quickstarts/workflows/`. + +## Flights + +Flights require an Apple pass certificate uploaded to PassKit. Copy its pass +type identifier into `.env`: + +```dotenv +PASSKIT_APPLE_CERTIFICATE=pass.com.example.airline +``` + +The default `YY` carrier and `YY4`/`ADP` airports are reused if they already +exist in your account. Reused infrastructure is never deleted. The flight +number and departure date are generated for each run. + +## Keep generated resources + +Cleanup runs even if an example fails partway through. To keep the generated +records for inspection, set: + +```dotenv +PASSKIT_KEEP_ASSETS=true +``` + +You must then delete them manually. Return the value to `false` for normal use. + +## Configuration reference + +| Variable | Default | Purpose | +| --- | --- | --- | +| `PASSKIT_PASSPHRASE` | Empty | Password required when `key.pem` is encrypted | +| `PASSKIT_ADDRESS` | `grpc.pub1.passkit.io` | PassKit API region hostname | +| `PASSKIT_PORT` | `443` | gRPC port | +| `PASSKIT_ROOT_CERT` | `certs/ca-chain.pem` | PassKit CA chain | +| `PASSKIT_PRIVATE_KEY` | `certs/key.pem` | SDK private key | +| `PASSKIT_CERTIFICATE` | `certs/certificate.pem` | SDK client certificate | +| `PASSKIT_CONNECTION_MODE` | `pool` | `pool` or `single` | +| `PASSKIT_POOL_SIZE` | `5` | Number of reusable connections | +| `PASSKIT_KEEP_ASSETS` | `false` | Keep resources created by a run | +| `PASSKIT_RECIPIENT_EMAIL` | Empty | Optional real recipient email | +| `PASSKIT_APPLE_CERTIFICATE` | Empty | Apple pass type identifier for flights | +| `PASSKIT_FLIGHT_CARRIER` | `YY` | Two-character carrier code | +| `PASSKIT_FLIGHT_ORIGIN` | `YY4` | Origin airport code | +| `PASSKIT_FLIGHT_DESTINATION` | `ADP` | Destination airport code | + +Values already set in your terminal take precedence over `.env`. + +## Use the complete SDK API + +`quickstarts/api.py` exposes all generated SDK services and methods. Print the +current operation list without credentials or a network connection: + +```bash +python main.py operations +``` + +Create the API with the same reusable connection pool used by the examples: + +```python +from passkit.io.common.common_objects_pb2 import Id + +from quickstarts.api import PassKitApi +from quickstarts.client import ConnectionPool +from quickstarts.config import Config + +config = Config.load() +config.validate() +pool = ConnectionPool(config) + +try: + api = PassKitApi(pool) + program = api.members.getProgram(Id(id="YOUR_PROGRAM_ID")) + print(program) +finally: + pool.close() +``` + +Server-streaming methods return normal Python iterators. Use +`api.members.collect("listPrograms", request)` when a small result should be +collected into a list. + +Sensitive account, credential, bulk-delete, and bulk-update operations are +blocked through the facade by default. They require an explicit opt-in: + +```python +api = PassKitApi(pool, allow_destructive=True) +``` + +## Tests and project checks + +These checks do not connect to PassKit or require credentials: + +```bash +python -m unittest discover -s tests -v +python -m ruff check . +python -m ruff format --check . +``` + +The live workflows create and delete PassKit resources and require `.env` and +the three credential files. + +## Troubleshooting + +### A credential file cannot be found + +Confirm all three `.pem` files are under `certs/` and run the command from the +repository root. + +### The private key cannot be decrypted + +`PASSKIT_PASSPHRASE` must be the password chosen when generating the SDK +credentials. It is not your PassKit login password. Replace all three files +together if you generate a new credential set. + +### Authentication or connection fails + +Confirm your PassKit API region. Also check whether a firewall or VPN blocks +outbound HTTPS/gRPC traffic. + +### Flights stop before connecting + +Upload an Apple pass certificate to PassKit and set +`PASSKIT_APPLE_CERTIFICATE` to its pass type identifier. + +### Resources remain after a failed run + +Remove them in the PassKit portal and ensure `PASSKIT_KEEP_ASSETS=false`. + +## Documentation and support + +- [PassKit API documentation](https://docs.passkit.io/) +- [PassKit Help Centre](https://help.passkit.com/) +- [Open an issue](https://github.com/PassKit/passkit-python-quickstart/issues) +- Email [support@passkit.com](mailto:support@passkit.com) + +## Licence + +Distributed under the [MIT Licence](LICENSE). diff --git a/assets/pass/hero.png b/assets/pass/hero.png new file mode 100755 index 0000000..6e09da0 Binary files /dev/null and b/assets/pass/hero.png differ diff --git a/assets/pass/strip.png b/assets/pass/strip.png new file mode 100755 index 0000000..fafa8bc Binary files /dev/null and b/assets/pass/strip.png differ diff --git a/assets/shared/icon.png b/assets/shared/icon.png new file mode 100755 index 0000000..81f8a92 Binary files /dev/null and b/assets/shared/icon.png differ diff --git a/assets/shared/logo.png b/assets/shared/logo.png new file mode 100755 index 0000000..eb57a5f Binary files /dev/null and b/assets/shared/logo.png differ diff --git a/coupons/README.md b/coupons/README.md new file mode 100644 index 0000000..20c13a7 --- /dev/null +++ b/coupons/README.md @@ -0,0 +1,8 @@ +# Coupon methods + +[`methods.py`](methods.py) provides one import point for creating, copying, +retrieving, updating and listing campaigns and offers; coupon lookup by ID or +external ID; issuing, counting, redeeming and voiding; and deletion. The +individual scripts remain available for backwards compatibility. + +Run the complete lifecycle with `python main.py coupons`. diff --git a/coupons/__init__.py b/coupons/__init__.py new file mode 100644 index 0000000..2157cae --- /dev/null +++ b/coupons/__init__.py @@ -0,0 +1,4 @@ +"""Focused PassKit coupon examples.""" + +from coupons.methods import * # noqa: F403 +from coupons.methods import __all__ diff --git a/coupons/methods.py b/coupons/methods.py new file mode 100644 index 0000000..b6c6e18 --- /dev/null +++ b/coupons/methods.py @@ -0,0 +1,91 @@ +"""Reusable coupon examples collected behind one consistent interface.""" + +from passkit.io.common.common_objects_pb2 import Id +from passkit.io.common.filter_pb2 import Filters +from passkit.io.single_use_coupons.a_rpc_pb2_grpc import SingleUseCouponsStub +from passkit.io.single_use_coupons.campaign_pb2 import CampaignCopyRequest +from passkit.io.single_use_coupons.coupon_pb2 import ExternalIdRequest, ListRequest +from passkit.io.single_use_coupons.offer_pb2 import CouponOffersListRequest + +from coupons.createCampaign import run as create_campaign +from coupons.createCoupon import run as create_coupon +from coupons.createOffer import run as create_offer +from coupons.deleteCampaign import run as delete_campaign +from coupons.listCoupons import run as list_coupons +from coupons.redeemCoupon import run as redeem_coupon +from coupons.updateCoupon import run as update_coupon +from coupons.voidCoupon import run as void_coupon + + +def _stub(channel): + return channel if isinstance(channel, SingleUseCouponsStub) else SingleUseCouponsStub(channel) + + +def get_campaign(channel, campaign_id: str): + return _stub(channel).getCouponCampaign(Id(id=campaign_id)) + + +def update_campaign(channel, campaign): + return _stub(channel).updateCouponCampaign(campaign) + + +def list_campaigns(channel): + return list(_stub(channel).listCouponCampaigns(Filters(limit=-1))) + + +def copy_campaign(channel, campaign_id: str, name: str): + return _stub(channel).copyCouponCampaign(CampaignCopyRequest(id=campaign_id, name=name)) + + +def get_offer(channel, offer_id: str): + return _stub(channel).getCouponOffer(Id(id=offer_id)) + + +def update_offer(channel, offer): + return _stub(channel).updateCouponOffer(offer) + + +def list_offers(channel, campaign_id: str): + request = CouponOffersListRequest(campaignId=campaign_id, filters=Filters(limit=-1)) + return list(_stub(channel).listCouponOffers(request)) + + +def delete_offer(channel, offer_id: str): + return _stub(channel).deleteCouponOffer(Id(id=offer_id)) + + +def get_coupon_by_id(channel, coupon_id: str): + return _stub(channel).getCouponById(Id(id=coupon_id)) + + +def get_coupon_by_external_id(channel, campaign_id: str, external_id: str): + return _stub(channel).getCouponByExternalId( + ExternalIdRequest(couponCampaignId=campaign_id, externalId=external_id) + ) + + +def count_coupons(channel, campaign_id: str): + return _stub(channel).countCouponsByCouponCampaign(ListRequest(couponCampaignId=campaign_id)) + + +__all__ = [ + "copy_campaign", + "count_coupons", + "create_campaign", + "create_coupon", + "create_offer", + "delete_campaign", + "delete_offer", + "get_campaign", + "get_coupon_by_external_id", + "get_coupon_by_id", + "get_offer", + "list_campaigns", + "list_coupons", + "list_offers", + "redeem_coupon", + "update_campaign", + "update_coupon", + "update_offer", + "void_coupon", +] diff --git a/coupons/redeemCoupon.py b/coupons/redeemCoupon.py index b9d8f60..2e2d5ec 100644 --- a/coupons/redeemCoupon.py +++ b/coupons/redeemCoupon.py @@ -1,4 +1,6 @@ # coupons/redeemCoupon.py +from __future__ import annotations + import grpc from passkit.io.single_use_coupons import a_rpc_pb2_grpc as coupons_grpc, coupon_pb2 diff --git a/event_tickets/README.md b/event_tickets/README.md new file mode 100644 index 0000000..82ff937 --- /dev/null +++ b/event_tickets/README.md @@ -0,0 +1,32 @@ +# Event ticket methods + +[`methods.py`](methods.py) contains focused functions for creating a production, +venue, event and ticket type; issuing, updating, finding, listing, counting, +validating and redeeming tickets; and deleting generated resources. + +The methods include resource creation, retrieval, update, list, copy and +deletion; ticket issue and lookup; downloadable pass retrieval; and individual +or order-level redemption and deletion. Each function accepts a gRPC channel, +so several calls can reuse one pooled connection: + +```python +from event_tickets import create_production +from quickstarts.client import ConnectionPool +from quickstarts.config import Config + +config = Config.load() +config.validate() +pool = ConnectionPool(config) + +try: + production_id = create_production(pool.service("event_tickets")) + print(production_id) +finally: + pool.close() +``` + +For a complete example with automatic dependency creation and cleanup, run: + +```bash +python main.py event-tickets +``` diff --git a/event_tickets/__init__.py b/event_tickets/__init__.py new file mode 100644 index 0000000..3e028ec --- /dev/null +++ b/event_tickets/__init__.py @@ -0,0 +1,77 @@ +"""Focused PassKit event-ticket example methods.""" + +from event_tickets.methods import ( + copy_production, + count_tickets, + create_event, + create_production, + create_ticket_type, + create_venue, + delete_event, + delete_production, + delete_ticket, + delete_ticket_type, + delete_tickets_by_order_number, + delete_venue, + get_event, + get_event_ticket_pass, + get_production, + get_ticket_by_id, + get_ticket_by_number, + get_ticket_type, + get_tickets_by_order_number, + get_venue, + issue_ticket, + issue_ticket_by_id, + list_events, + list_productions, + list_ticket_types, + list_tickets, + list_venues, + redeem_ticket, + redeem_tickets_by_order_number, + update_event, + update_production, + update_ticket, + update_ticket_type, + update_venue, + validate_ticket, +) + +__all__ = [ + "count_tickets", + "copy_production", + "create_event", + "create_production", + "create_ticket_type", + "create_venue", + "delete_event", + "delete_production", + "delete_ticket", + "delete_ticket_type", + "delete_tickets_by_order_number", + "delete_venue", + "get_event", + "get_event_ticket_pass", + "get_production", + "get_ticket_by_id", + "get_ticket_by_number", + "get_tickets_by_order_number", + "get_ticket_type", + "get_venue", + "issue_ticket", + "issue_ticket_by_id", + "list_events", + "list_productions", + "list_ticket_types", + "list_tickets", + "list_venues", + "redeem_ticket", + "redeem_tickets_by_order_number", + "update_event", + "update_production", + "update_ticket", + "update_ticket_type", + "update_venue", + "validate_ticket", +] diff --git a/event_tickets/methods.py b/event_tickets/methods.py new file mode 100644 index 0000000..a282e03 --- /dev/null +++ b/event_tickets/methods.py @@ -0,0 +1,248 @@ +"""Reusable, focused examples for the PassKit Event Tickets API.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import grpc +from passkit.io.common.common_objects_pb2 import Id +from passkit.io.common.filter_pb2 import Filters +from passkit.io.common.personal_pb2 import Person +from passkit.io.common.project_pb2 import ProjectStatus +from passkit.io.event_tickets.a_rpc_pb2_grpc import EventTicketsStub +from passkit.io.event_tickets.event_pb2 import Event, EventListRequest +from passkit.io.event_tickets.production_pb2 import Production, ProductionCopyRequest +from passkit.io.event_tickets.ticket_pb2 import ( + EventTicketPassRequest, + IssueTicketRequest, + OrderNumberRequest, + RedeemByOrderNumber, + RedeemTicketRequest, + Ticket, + TicketId, + TicketListRequest, + TicketNumberRequest, + ValidateTicketRequest, +) +from passkit.io.event_tickets.ticket_type_pb2 import TicketType, TicketTypeListRequest +from passkit.io.event_tickets.venue_pb2 import Venue + +from quickstarts.workflow import timestamp + + +def _stub(channel_or_stub: grpc.Channel | EventTicketsStub) -> EventTicketsStub: + """Accept either a channel or a pooled EventTicketsStub.""" + if isinstance(channel_or_stub, EventTicketsStub): + return channel_or_stub + return EventTicketsStub(channel_or_stub) + + +def create_production(channel: grpc.Channel, name: str = "Python Quickstart Production") -> str: + response = _stub(channel).createProduction( + Production( + name=name, + finePrint="Quickstart demonstration only.", + status=[ + ProjectStatus.PROJECT_DRAFT, + ProjectStatus.PROJECT_ACTIVE_FOR_OBJECT_CREATION, + ], + ) + ) + return response.id + + +def create_venue(channel: grpc.Channel, name: str = "Python Quickstart Venue") -> str: + response = _stub(channel).createVenue( + Venue(name=name, address="123 Quickstart Street, London", timezone="Europe/London") + ) + return response.id + + +def create_event(channel: grpc.Channel, production_id: str, venue_id: str) -> str: + start = datetime.now(timezone.utc) + timedelta(days=7) + response = _stub(channel).createEvent( + Event( + production=Production(id=production_id), + venue=Venue(id=venue_id), + doorsOpen=timestamp(start - timedelta(hours=1)), + scheduledStartDate=timestamp(start), + relevantDate=timestamp(start), + endDate=timestamp(start + timedelta(hours=3)), + ) + ) + return response.id + + +def create_ticket_type(channel: grpc.Channel, production_id: str, template_id: str) -> str: + response = _stub(channel).createTicketType( + TicketType( + name="Python Quickstart General Admission", + productionId=production_id, + beforeRedeemPassTemplateId=template_id, + ) + ) + return response.id + + +def issue_ticket( + channel: grpc.Channel, + event_id: str, + ticket_type_id: str, + *, + ticket_number: str, + order_number: str, + email: str, +) -> str: + response = _stub(channel).issueTicket( + IssueTicketRequest( + eventId=event_id, + ticketTypeId=ticket_type_id, + ticketNumber=ticket_number, + orderNumber=order_number, + person=Person(displayName="Python Ticket Holder", emailAddress=email), + ) + ) + return response.id + + +def update_ticket(channel: grpc.Channel, ticket_id: str, display_name: str) -> object: + return _stub(channel).updateTicket( + Ticket(id=ticket_id, person=Person(displayName=display_name)) + ) + + +def get_production(channel: grpc.Channel, production_id: str) -> object: + return _stub(channel).getProduction(Id(id=production_id)) + + +def update_production(channel: grpc.Channel, production: Production) -> object: + return _stub(channel).updateProduction(production) + + +def list_productions(channel: grpc.Channel) -> list[object]: + return list(_stub(channel).listProductions(Filters(limit=-1))) + + +def copy_production(channel: grpc.Channel, production_id: str, name: str) -> object: + return _stub(channel).copyProduction(ProductionCopyRequest(id=production_id, name=name)) + + +def get_venue(channel: grpc.Channel, venue_id: str) -> object: + return _stub(channel).getVenueById(Id(id=venue_id)) + + +def update_venue(channel: grpc.Channel, venue: Venue) -> object: + return _stub(channel).updateVenue(venue) + + +def list_venues(channel: grpc.Channel) -> list[object]: + return list(_stub(channel).listVenues(Filters(limit=-1))) + + +def get_event(channel: grpc.Channel, event_id: str) -> object: + return _stub(channel).getEventById(Id(id=event_id)) + + +def update_event(channel: grpc.Channel, event: Event) -> object: + return _stub(channel).updateEvent(event) + + +def list_events(channel: grpc.Channel, production_id: str) -> list[object]: + return list(_stub(channel).listEvents(EventListRequest(productionId=production_id))) + + +def get_ticket_type(channel: grpc.Channel, ticket_type_id: str) -> object: + return _stub(channel).getTicketTypeById(Id(id=ticket_type_id)) + + +def update_ticket_type(channel: grpc.Channel, ticket_type: TicketType) -> object: + return _stub(channel).updateTicketType(ticket_type) + + +def list_ticket_types(channel: grpc.Channel, production_id: str) -> list[object]: + return list(_stub(channel).listTicketTypes(TicketTypeListRequest(productionId=production_id))) + + +def issue_ticket_by_id(channel: grpc.Channel, ticket: Ticket) -> object: + return _stub(channel).issueTicketById(ticket) + + +def get_event_ticket_pass(channel: grpc.Channel, ticket_id: str) -> object: + return _stub(channel).getEventTicketPass(EventTicketPassRequest(ticketId=Id(id=ticket_id))) + + +def redeem_tickets_by_order_number( + channel: grpc.Channel, production_id: str, order_number: str +) -> object: + return _stub(channel).redeemTicketsByOrderNumber( + RedeemByOrderNumber(productionId=production_id, orderNumber=order_number) + ) + + +def delete_tickets_by_order_number( + channel: grpc.Channel, production_id: str, order_number: str +) -> object: + return _stub(channel).deleteTicketsByOrderNumber( + OrderNumberRequest(productionId=production_id, orderNumber=order_number) + ) + + +def get_ticket_by_id(channel: grpc.Channel, ticket_id: str) -> object: + return _stub(channel).getTicketById(Id(id=ticket_id)) + + +def get_ticket_by_number(channel: grpc.Channel, production_id: str, ticket_number: str) -> object: + return _stub(channel).getTicketByTicketNumber( + TicketNumberRequest(productionId=production_id, ticketNumber=ticket_number) + ) + + +def get_tickets_by_order_number( + channel: grpc.Channel, production_id: str, order_number: str +) -> object: + return _stub(channel).getTicketsByOrderNumber( + OrderNumberRequest(productionId=production_id, orderNumber=order_number) + ) + + +def list_tickets(channel: grpc.Channel, production_id: str, event_id: str) -> list[object]: + request = TicketListRequest( + productionId=production_id, eventId=event_id, filters=Filters(limit=-1) + ) + return list(_stub(channel).listTickets(request)) + + +def count_tickets(channel: grpc.Channel, production_id: str, event_id: str) -> object: + return _stub(channel).countTickets( + TicketListRequest(productionId=production_id, eventId=event_id) + ) + + +def validate_ticket(channel: grpc.Channel, ticket_id: str, maximum: int = 3) -> object: + return _stub(channel).validateTicket( + ValidateTicketRequest(maxNumberOfValidations=maximum, ticket=TicketId(ticketId=ticket_id)) + ) + + +def redeem_ticket(channel: grpc.Channel, ticket_id: str) -> object: + return _stub(channel).redeemTicket(RedeemTicketRequest(ticket=TicketId(ticketId=ticket_id))) + + +def delete_ticket(channel: grpc.Channel, ticket_id: str) -> object: + return _stub(channel).deleteTicket(TicketId(ticketId=ticket_id)) + + +def delete_ticket_type(channel: grpc.Channel, ticket_type_id: str) -> object: + return _stub(channel).deleteTicketType(TicketType(id=ticket_type_id)) + + +def delete_event(channel: grpc.Channel, event_id: str) -> object: + return _stub(channel).deleteEvent(Event(id=event_id)) + + +def delete_venue(channel: grpc.Channel, venue_id: str) -> object: + return _stub(channel).deleteVenue(Venue(id=venue_id)) + + +def delete_production(channel: grpc.Channel, production_id: str) -> object: + return _stub(channel).deleteProduction(Production(id=production_id)) diff --git a/flights/README.md b/flights/README.md new file mode 100644 index 0000000..c78b0b7 --- /dev/null +++ b/flights/README.md @@ -0,0 +1,8 @@ +# Flight methods + +[`methods.py`](methods.py) provides one import point for creating, retrieving, +updating and deleting carriers, airports, flights and flight designators, plus +boarding-pass creation, record lookup, download and update. The individual +scripts remain available for backwards compatibility. + +Run the complete lifecycle with `python main.py flights`. diff --git a/flights/__init__.py b/flights/__init__.py new file mode 100644 index 0000000..15f33e8 --- /dev/null +++ b/flights/__init__.py @@ -0,0 +1,4 @@ +"""Focused PassKit flight examples.""" + +from flights.methods import * # noqa: F403 +from flights.methods import __all__ diff --git a/flights/createFlight.py b/flights/createFlight.py index f13eaf5..595ee2e 100644 --- a/flights/createFlight.py +++ b/flights/createFlight.py @@ -1,4 +1,6 @@ # flights/createFlight.py +from __future__ import annotations + import grpc import datetime from typing import Optional, Tuple diff --git a/flights/deleteAirport.py b/flights/deleteAirport.py index ef460ac..2682c65 100644 --- a/flights/deleteAirport.py +++ b/flights/deleteAirport.py @@ -1,4 +1,6 @@ # flights/deleteAirport.py +from __future__ import annotations + import grpc from passkit.io.flights import a_rpc_pb2_grpc as flights_grpc, airport_pb2 diff --git a/flights/deleteCarrier.py b/flights/deleteCarrier.py index 9845ca3..7afb399 100644 --- a/flights/deleteCarrier.py +++ b/flights/deleteCarrier.py @@ -1,4 +1,6 @@ # flights/deleteCarrier.py +from __future__ import annotations + import grpc from passkit.io.flights import a_rpc_pb2_grpc as flights_grpc, carrier_pb2 diff --git a/flights/methods.py b/flights/methods.py new file mode 100644 index 0000000..62edbe7 --- /dev/null +++ b/flights/methods.py @@ -0,0 +1,89 @@ +"""Reusable flight examples collected behind one consistent interface.""" + +from passkit.io.common.common_objects_pb2 import Id +from passkit.io.flights.a_rpc_pb2_grpc import FlightsStub +from passkit.io.flights.airport_pb2 import AirportCode +from passkit.io.flights.boarding_pass_pb2 import BoardingPassRequest +from passkit.io.flights.carrier_pb2 import CarrierCode + +from flights.createAirport import run as create_airport +from flights.createBoardingPass import run as create_boarding_pass +from flights.createCarrier import run as create_carrier +from flights.createFlight import run as create_flight +from flights.createFlightDesignator import run as create_flight_designator +from flights.deleteAirport import run as delete_airport +from flights.deleteCarrier import run as delete_carrier +from flights.deleteFlight import run as delete_flight +from flights.deleteFlightDesignator import run as delete_flight_designator + + +def _stub(channel): + return channel if isinstance(channel, FlightsStub) else FlightsStub(channel) + + +def get_carrier(channel, carrier_code: str): + return _stub(channel).getCarrier(CarrierCode(carrierCode=carrier_code)) + + +def update_carrier(channel, carrier): + return _stub(channel).updateCarrier(carrier) + + +def get_airport(channel, airport_code: str): + return _stub(channel).getPort(AirportCode(airportCode=airport_code)) + + +def update_airport(channel, airport): + return _stub(channel).updatePort(airport) + + +def get_flight(channel, request): + return _stub(channel).getFlight(request) + + +def update_flight(channel, flight): + return _stub(channel).updateFlight(flight) + + +def get_flight_designator(channel, request): + return _stub(channel).getFlightDesignator(request) + + +def update_flight_designator(channel, designator): + return _stub(channel).updateFlightDesignator(designator) + + +def get_boarding_pass_record(channel, request): + return _stub(channel).getBoardingPassRecord(request) + + +def get_boarding_pass(channel, pass_id: str): + return _stub(channel).getBoardingPass(BoardingPassRequest(passId=Id(id=pass_id))) + + +def update_boarding_pass(channel, boarding_pass): + return _stub(channel).updateBoardingPass(boarding_pass) + + +__all__ = [ + "create_airport", + "create_boarding_pass", + "create_carrier", + "create_flight", + "create_flight_designator", + "delete_airport", + "delete_carrier", + "delete_flight", + "delete_flight_designator", + "get_airport", + "get_boarding_pass", + "get_boarding_pass_record", + "get_carrier", + "get_flight", + "get_flight_designator", + "update_airport", + "update_boarding_pass", + "update_carrier", + "update_flight", + "update_flight_designator", +] diff --git a/main.py b/main.py new file mode 100644 index 0000000..1eec554 --- /dev/null +++ b/main.py @@ -0,0 +1,76 @@ +"""Command-line entry point for the PassKit Python quickstart.""" + +from __future__ import annotations + +import argparse + +from quickstarts.api import PassKitApi +from quickstarts.client import ConnectionPool +from quickstarts.config import Config +from quickstarts.workflows.coupons import CouponsWorkflow +from quickstarts.workflows.event_tickets import EventTicketsWorkflow +from quickstarts.workflows.flights import FlightsWorkflow +from quickstarts.workflows.membership import MembershipWorkflow + +WORKFLOWS = { + "membership": MembershipWorkflow, + "loyalty": MembershipWorkflow, + "coupons": CouponsWorkflow, + "event-tickets": EventTicketsWorkflow, + "tickets": EventTicketsWorkflow, + "flights": FlightsWorkflow, +} + + +def pass_url(config: Config, identifier: str) -> str: + region = "pub2" if "pub2" in config.address else "pub1" + return f"https://{region}.pskt.io/{identifier}" + + +def run(command: str) -> None: + if command == "operations": + operations = PassKitApi.operations() + total = sum(len(methods) for methods in operations.values()) + for service, methods in operations.items(): + print(f"{service} ({len(methods)}): {', '.join(methods)}") + print(f"\n{total} SDK operations are available through PassKitApi.") + return + + config = Config.load() + config.validate(require_flights=command == "flights") + pool = ConnectionPool(config) + workflow = WORKFLOWS[command](pool, config) + try: + results = workflow.execute() + print("Created resources:") + for label, value in results.items(): + output = value if label.lower().endswith("url") else pass_url(config, value) + print(f" {label}: {output}") + finally: + try: + workflow.cleanup() + finally: + pool.close() + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run a PassKit Python quickstart") + parser.add_argument( + "command", + choices=[ + "membership", + "loyalty", + "coupons", + "event-tickets", + "tickets", + "flights", + "operations", + ], + help="product workflow or SDK operation listing", + ) + args = parser.parse_args() + run(args.command) + + +if __name__ == "__main__": + main() diff --git a/membership/README.md b/membership/README.md new file mode 100644 index 0000000..ccfd431 --- /dev/null +++ b/membership/README.md @@ -0,0 +1,9 @@ +# Membership methods + +[`methods.py`](methods.py) provides one import point for creating, retrieving, +and listing programs and tiers; member enrolment, lookup and updates; +check-in/out; setting, earning and burning points; changing tiers; member event +history; and deletion. The individual scripts remain available for backwards +compatibility. + +Run the complete lifecycle with `python main.py membership`. diff --git a/membership/__init__.py b/membership/__init__.py new file mode 100644 index 0000000..7c73922 --- /dev/null +++ b/membership/__init__.py @@ -0,0 +1,4 @@ +"""Focused PassKit membership examples.""" + +from membership.methods import * # noqa: F403 +from membership.methods import __all__ diff --git a/membership/methods.py b/membership/methods.py new file mode 100644 index 0000000..8f538f4 --- /dev/null +++ b/membership/methods.py @@ -0,0 +1,109 @@ +"""Reusable membership examples collected behind one consistent interface.""" + +from passkit.io.common.common_objects_pb2 import Id +from passkit.io.common.filter_pb2 import Filters +from passkit.io.member.a_rpc_pb2_grpc import MembersStub +from passkit.io.member.member_pb2 import ( + ChangeTierRequest, + ListRequest, + MemberRecordByExternalIdRequest, + SetPointsRequest, +) +from passkit.io.member.tier_pb2 import TierRequestInput + +from membership.burnPoints import run as burn_points +from membership.checkInMember import run as check_in_member +from membership.checkOutMember import run as check_out_member +from membership.createProgram import run as create_program +from membership.createTier import run as create_tier +from membership.deleteMember import run as delete_member +from membership.deleteProgram import run as delete_program +from membership.earnPoints import run as earn_points +from membership.enrolMember import run as enrol_member +from membership.updateMember import run as update_member + + +def _stub(channel): + return channel if isinstance(channel, MembersStub) else MembersStub(channel) + + +def get_program(channel, program_id: str): + return _stub(channel).getProgram(Id(id=program_id)) + + +def list_programs(channel): + return list(_stub(channel).listPrograms(Filters(limit=-1))) + + +def get_tier(channel, program_id: str, tier_id: str): + return _stub(channel).getTier(TierRequestInput(programId=program_id, tierId=tier_id)) + + +def list_tiers(channel, program_id: str): + request = ListRequest(programId=program_id, filters=Filters(limit=-1)) + return list(_stub(channel).listTiers(request)) + + +def get_member_by_id(channel, member_id: str): + return _stub(channel).getMemberRecordById(Id(id=member_id)) + + +def get_member_by_external_id(channel, program_id: str, external_id: str): + return _stub(channel).getMemberRecordByExternalId( + MemberRecordByExternalIdRequest(programId=program_id, externalId=external_id) + ) + + +def list_members(channel, program_id: str): + request = ListRequest(programId=program_id, filters=Filters(limit=-1)) + return list(_stub(channel).listMembers(request)) + + +def count_members(channel, program_id: str): + return _stub(channel).countMembers(ListRequest(programId=program_id)) + + +def change_member_tier(channel, member_id: str, program_id: str, tier_id: str): + return _stub(channel).changeMemberTier( + ChangeTierRequest(memberId=member_id, programId=program_id, tierId=tier_id) + ) + + +def set_points(channel, member_id: str, program_id: str, points: float): + return _stub(channel).setPoints( + SetPointsRequest(id=member_id, programId=program_id, points=points) + ) + + +def list_member_events(channel, member_id: str): + return list(_stub(channel).listEventsForMember(Id(id=member_id))) + + +def delete_member_events(channel, member_id: str): + return _stub(channel).deleteEventsForMember(Id(id=member_id)) + + +__all__ = [ + "burn_points", + "change_member_tier", + "check_in_member", + "check_out_member", + "count_members", + "create_program", + "create_tier", + "delete_member", + "delete_member_events", + "delete_program", + "earn_points", + "enrol_member", + "get_member_by_external_id", + "get_member_by_id", + "get_program", + "get_tier", + "list_member_events", + "list_members", + "list_programs", + "list_tiers", + "set_points", + "update_member", +] diff --git a/membership/updateMember.py b/membership/updateMember.py index 49411bd..e7cd22e 100644 --- a/membership/updateMember.py +++ b/membership/updateMember.py @@ -1,4 +1,6 @@ # membership/updateMember.py +from __future__ import annotations + import grpc from passkit.io.member import a_rpc_pb2_grpc as member_grpc, member_pb2 from passkit.io.common import personal_pb2 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..abf07d2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,52 @@ +[build-system] +requires = [ + "setuptools>=82.0.1,<83; python_version < '3.10'", + "setuptools>=84.0.0; python_version >= '3.10'", +] +build-backend = "setuptools.build_meta" + +[project] +name = "passkit-python-quickstart" +version = "1.0.0" +description = "Runnable examples for the PassKit Python gRPC SDK" +readme = "README.md" +requires-python = ">=3.9" +license = "MIT" +license-files = ["LICENSE"] +dependencies = [ + "cryptography>=50.0.1,<51", + "passkit-python-grpc-sdk==1.1.162", + "python-dotenv>=1.1,<2", +] + +[project.optional-dependencies] +dev = ["ruff>=0.13,<1"] + +[project.scripts] +passkit-quickstart = "main:main" + +[tool.setuptools] +packages = [ + "coupons", + "event_tickets", + "flights", + "membership", + "quickstarts", + "quickstarts.workflows", +] +py-modules = ["main"] + +[tool.ruff] +line-length = 100 +target-version = "py39" +exclude = [ + "constants.py", + "coupons", + "flights", + "membership", + "quickstart-all.py", + "shared", +] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP"] diff --git a/quickstarts/__init__.py b/quickstarts/__init__.py new file mode 100644 index 0000000..533d7df --- /dev/null +++ b/quickstarts/__init__.py @@ -0,0 +1 @@ +"""Developer-friendly PassKit Python quickstart helpers.""" diff --git a/quickstarts/api.py b/quickstarts/api.py new file mode 100644 index 0000000..bfdf004 --- /dev/null +++ b/quickstarts/api.py @@ -0,0 +1,72 @@ +"""Complete, low-level access to the generated PassKit SDK service methods.""" + +from __future__ import annotations + +from quickstarts.client import STUB_CLASSES + +DESTRUCTIVE_METHODS = { + "addAppleCertificate", + "bulkDeleteMembers", + "bulkDeleteTickets", + "bulkVoidCoupons", + "changeOwnPassword", + "changePassword", + "deleteAccount", + "deleteMembersBySegment", + "refreshApiSecret", + "resetPassword", + "revokeLegacyCredentials", + "sendNFCSigningCredentials", + "updateMembersBySegment", +} + + +class Service: + def __init__(self, pool, name: str, allow_destructive: bool): + self._pool = pool + self._name = name + self._allow_destructive = allow_destructive + + def __getattr__(self, method: str): + if method in DESTRUCTIVE_METHODS and not self._allow_destructive: + + def blocked(*_args, **_kwargs): + raise PermissionError( + f"{method} is a sensitive operation; construct PassKitApi with " + "allow_destructive=True to enable it" + ) + + return blocked + candidate = getattr(self._pool.service(self._name), method, None) + if candidate is None: + raise AttributeError(f"Unknown {self._name} SDK method: {method}") + return candidate + + def collect(self, method: str, request, **kwargs) -> list: + return list(getattr(self, method)(request, **kwargs)) + + +class PassKitApi: + def __init__(self, pool, *, allow_destructive: bool = False): + self._pool = pool + self._allow_destructive = allow_destructive + + def __getattr__(self, service: str) -> Service: + if service not in STUB_CLASSES: + raise AttributeError(f"Unknown PassKit service: {service}") + return Service(self._pool, service, self._allow_destructive) + + @staticmethod + def operations() -> dict[str, list[str]]: + operations: dict[str, list[str]] = {} + for service, stub_class in STUB_CLASSES.items(): + method_names = [] + # Generated stubs attach RPC callables in __init__, so use the paired + # service descriptor embedded in the module's imported pb2 module. + pb2 = __import__(stub_class.__module__.removesuffix("_grpc"), fromlist=["DESCRIPTOR"]) + for definition in pb2.DESCRIPTOR.services_by_name.values(): + if definition.name == stub_class.__name__.removesuffix("Stub"): + method_names = [method.name for method in definition.methods] + break + operations[service] = method_names + return operations diff --git a/quickstarts/client.py b/quickstarts/client.py new file mode 100644 index 0000000..45e8f9c --- /dev/null +++ b/quickstarts/client.py @@ -0,0 +1,86 @@ +"""Mutual-TLS gRPC connections and round-robin pooling.""" + +from __future__ import annotations + +import threading + +import grpc +from cryptography.hazmat.primitives import serialization +from passkit.io.analytics.a_rpc_pb2_grpc import AnalyticsStub +from passkit.io.core.a_rpc_certificates_pb2_grpc import CertificatesStub +from passkit.io.core.a_rpc_distribution_pb2_grpc import DistributionStub +from passkit.io.core.a_rpc_images_pb2_grpc import ImagesStub +from passkit.io.core.a_rpc_messages_pb2_grpc import MessagesStub +from passkit.io.core.a_rpc_others_pb2_grpc import IntegrationsStub, UsersStub +from passkit.io.core.a_rpc_templates_pb2_grpc import TemplatesStub +from passkit.io.event_tickets.a_rpc_pb2_grpc import EventTicketsStub +from passkit.io.flights.a_rpc_pb2_grpc import FlightsStub +from passkit.io.member.a_rpc_pb2_grpc import MembersStub +from passkit.io.raw.a_rpc_pb2_grpc import RawStub +from passkit.io.scheduler.a_rpc_pb2_grpc import SchedulerStub +from passkit.io.single_use_coupons.a_rpc_pb2_grpc import SingleUseCouponsStub + +from quickstarts.config import Config + +STUB_CLASSES = { + "analytics": AnalyticsStub, + "certificates": CertificatesStub, + "distribution": DistributionStub, + "images": ImagesStub, + "messages": MessagesStub, + "integrations": IntegrationsStub, + "users": UsersStub, + "templates": TemplatesStub, + "event_tickets": EventTicketsStub, + "flights": FlightsStub, + "members": MembersStub, + "raw": RawStub, + "scheduler": SchedulerStub, + "coupons": SingleUseCouponsStub, +} + + +def _private_key(path, passphrase: str | None) -> bytes: + pem = path.read_bytes() + if b"ENCRYPTED" not in pem: + return pem + if not passphrase: + raise ValueError("PASSKIT_PASSPHRASE is required for the encrypted private key") + key = serialization.load_pem_private_key(pem, password=passphrase.encode()) + return key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + + +class Connection: + def __init__(self, config: Config): + credentials = grpc.ssl_channel_credentials( + root_certificates=config.root_certificate.read_bytes(), + private_key=_private_key(config.private_key, config.passphrase), + certificate_chain=config.certificate.read_bytes(), + ) + self.channel = grpc.secure_channel(config.address, credentials) + self.stubs = {name: cls(self.channel) for name, cls in STUB_CLASSES.items()} + + def close(self) -> None: + self.channel.close() + + +class ConnectionPool: + def __init__(self, config: Config): + size = 1 if config.connection_mode == "single" else config.pool_size + self._connections = [Connection(config) for _ in range(size)] + self._index = 0 + self._lock = threading.Lock() + + def service(self, name: str): + with self._lock: + connection = self._connections[self._index] + self._index = (self._index + 1) % len(self._connections) + return connection.stubs[name] + + def close(self) -> None: + for connection in self._connections: + connection.close() diff --git a/quickstarts/config.py b/quickstarts/config.py new file mode 100644 index 0000000..845d332 --- /dev/null +++ b/quickstarts/config.py @@ -0,0 +1,71 @@ +"""Environment-based quickstart configuration.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +from dotenv import load_dotenv + +ROOT = Path(__file__).resolve().parents[1] + + +def _path(value: str) -> Path: + path = Path(value).expanduser() + return path if path.is_absolute() else ROOT / path + + +@dataclass(frozen=True) +class Config: + address: str + root_certificate: Path + private_key: Path + certificate: Path + passphrase: str | None + connection_mode: str + pool_size: int + keep_assets: bool + recipient_email: str | None + apple_certificate: str | None + carrier_code: str + origin: str + destination: str + + @classmethod + def load(cls) -> Config: + load_dotenv(ROOT / ".env", override=False) + host = os.getenv("PASSKIT_ADDRESS", "grpc.pub1.passkit.io") + port = os.getenv("PASSKIT_PORT", "443") + return cls( + address=f"{host}:{port}", + root_certificate=_path(os.getenv("PASSKIT_ROOT_CERT", "certs/ca-chain.pem")), + private_key=_path(os.getenv("PASSKIT_PRIVATE_KEY", "certs/key.pem")), + certificate=_path(os.getenv("PASSKIT_CERTIFICATE", "certs/certificate.pem")), + passphrase=os.getenv("PASSKIT_PASSPHRASE") or None, + connection_mode=os.getenv("PASSKIT_CONNECTION_MODE", "pool").lower(), + pool_size=int(os.getenv("PASSKIT_POOL_SIZE", "5")), + keep_assets=os.getenv("PASSKIT_KEEP_ASSETS", "false").lower() == "true", + recipient_email=os.getenv("PASSKIT_RECIPIENT_EMAIL") or None, + apple_certificate=os.getenv("PASSKIT_APPLE_CERTIFICATE") or None, + carrier_code=os.getenv("PASSKIT_FLIGHT_CARRIER", "YY").upper(), + origin=os.getenv("PASSKIT_FLIGHT_ORIGIN", "YY4").upper(), + destination=os.getenv("PASSKIT_FLIGHT_DESTINATION", "ADP").upper(), + ) + + def validate(self, *, require_flights: bool = False, check_files: bool = True) -> None: + errors: list[str] = [] + if self.connection_mode not in {"single", "pool"}: + errors.append('PASSKIT_CONNECTION_MODE must be "single" or "pool"') + if self.pool_size < 1: + errors.append("PASSKIT_POOL_SIZE must be a positive integer") + if require_flights and not self.apple_certificate: + errors.append("PASSKIT_APPLE_CERTIFICATE is required for flights") + if len(self.carrier_code) != 2: + errors.append("PASSKIT_FLIGHT_CARRIER must be a two-character IATA code") + if check_files: + for path in (self.root_certificate, self.private_key, self.certificate): + if not path.is_file(): + errors.append(f"Credential file not found: {path}") + if errors: + raise ValueError("Invalid PassKit configuration:\n- " + "\n- ".join(errors)) diff --git a/quickstarts/workflow.py b/quickstarts/workflow.py new file mode 100644 index 0000000..689e35c --- /dev/null +++ b/quickstarts/workflow.py @@ -0,0 +1,99 @@ +"""Shared helpers used by all four quickstart workflows.""" + +from __future__ import annotations + +import base64 +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable + +import grpc +from google.protobuf.timestamp_pb2 import Timestamp +from passkit.io.common.common_objects_pb2 import Id +from passkit.io.common.protocols_pb2 import PassProtocol +from passkit.io.common.template_pb2 import DefaultTemplateRequest +from passkit.io.image.image_pb2 import CreateImageInput, ImageData, ImageIds + +ROOT = Path(__file__).resolve().parents[1] + + +def timestamp(value: datetime) -> Timestamp: + result = Timestamp() + result.FromDatetime(value.astimezone(timezone.utc)) + return result + + +def image_string(path: Path) -> str: + return base64.b64encode(path.read_bytes()).decode("ascii") + + +class Workflow: + protocol: int = PassProtocol.PASS_PROTOCOL_DO_NOT_USE + + def __init__(self, pool, config): + self.pool = pool + self.config = config + self.cleanup_actions: list[tuple[str, Callable[[], object]]] = [] + self.image_ids = ImageIds() + + def service(self, name: str): + return self.pool.service(name) + + def remember(self, label: str, callback) -> None: + self.cleanup_actions.append((label, callback)) + + def create_images(self) -> ImageIds: + icon = image_string(ROOT / "assets/shared/icon.png") + logo = image_string(ROOT / "assets/shared/logo.png") + hero = image_string(ROOT / "assets/pass/hero.png") + strip = image_string(ROOT / "assets/pass/strip.png") + data = ImageData( + icon=icon, + logo=logo, + appleLogo=logo, + hero=hero, + eventStrip=strip, + strip=strip, + ) + self.image_ids = self.service("images").createImages( + CreateImageInput(name="Python quickstart images", imageData=data) + ) + for field in self.image_ids.DESCRIPTOR.fields: + image_id = getattr(self.image_ids, field.name) + if image_id: + self.remember( + f"{field.name} image", + lambda value=image_id: self.service("images").deleteImage(Id(id=value)), + ) + return self.image_ids + + def create_template(self, name: str, color: str = "#1F4E79") -> str: + template = self.service("templates").getDefaultTemplate( + DefaultTemplateRequest(protocol=self.protocol, revision=1) + ) + template.name = name + template.description = f"{name} pass" + template.timezone = "Europe/London" + template.imageIds.CopyFrom(self.image_ids) + template.ClearField("images") + template.colors.backgroundColor = color + result = self.service("templates").createTemplate(template) + self.remember( + f"{name} template", + lambda: self.service("templates").deleteTemplate(Id(id=result.id)), + ) + return result.id + + def cleanup(self) -> None: + if self.config.keep_assets: + print("PASSKIT_KEEP_ASSETS=true; generated resources were not deleted.") + return + print("Cleaning up generated resources...") + for label, callback in reversed(self.cleanup_actions): + try: + callback() + print(f"Deleted {label}.") + except grpc.RpcError as error: + if error.code() == grpc.StatusCode.NOT_FOUND: + continue + print(f"Could not delete {label}: {error.details()}") diff --git a/quickstarts/workflows/__init__.py b/quickstarts/workflows/__init__.py new file mode 100644 index 0000000..e000418 --- /dev/null +++ b/quickstarts/workflows/__init__.py @@ -0,0 +1 @@ +"""Runnable PassKit product workflows.""" diff --git a/quickstarts/workflows/coupons.py b/quickstarts/workflows/coupons.py new file mode 100644 index 0000000..48c6174 --- /dev/null +++ b/quickstarts/workflows/coupons.py @@ -0,0 +1,118 @@ +"""Single-use coupon lifecycle example.""" + +from datetime import datetime, timedelta, timezone + +from passkit.io.common.common_objects_pb2 import Id +from passkit.io.common.filter_pb2 import Filters +from passkit.io.common.personal_pb2 import Person +from passkit.io.common.project_pb2 import ProjectStatus +from passkit.io.common.protocols_pb2 import PassProtocol +from passkit.io.single_use_coupons.campaign_pb2 import CouponCampaign +from passkit.io.single_use_coupons.coupon_pb2 import Coupon, ListRequest +from passkit.io.single_use_coupons.offer_pb2 import ( + CouponExpirySettings, + CouponExpiryType, + CouponOffer, + RedemptionSettings, +) + +from quickstarts.workflow import Workflow, timestamp + + +class CouponsWorkflow(Workflow): + protocol = PassProtocol.SINGLE_USE_COUPON + + def execute(self) -> dict[str, str]: + print("Creating coupon images and templates") + self.create_images() + base_template = self.create_template("Python Quickstart Coupon") + redeemed_template = self.create_template("Python Quickstart Redeemed Coupon", "#777777") + + print("Creating campaign and offers") + campaign_id = ( + self.service("coupons") + .createCouponCampaign( + CouponCampaign( + name="Python Quickstart Coupon Campaign", + ianaTimezone="Europe/London", + status=[ + ProjectStatus.PROJECT_DRAFT, + ProjectStatus.PROJECT_ACTIVE_FOR_OBJECT_CREATION, + ], + ) + ) + .id + ) + self.remember( + "coupon campaign", + lambda: self.service("coupons").deleteCouponCampaign(Id(id=campaign_id)), + ) + now = datetime.now(timezone.utc) + + def create_offer(title: str, short_title: str) -> str: + return ( + self.service("coupons") + .createCouponOffer( + CouponOffer( + offerTitle=title, + offerShortTitle=short_title, + offerDetails="Save 10% with this Python quickstart coupon.", + offerFinePrint="Quickstart demonstration only.", + beforeRedeemPassTemplateId=base_template, + afterRedeemPassTemplateId=redeemed_template, + campaignId=campaign_id, + ianaTimezone="Europe/London", + issueStartDate=timestamp(now - timedelta(minutes=1)), + issueEndDate=timestamp(now + timedelta(days=30)), + couponExpirySettings=CouponExpirySettings( + couponExpiryType=CouponExpiryType.AUTO_EXPIRE_REDEMPTION_END_DATE + ), + redemptionSettings=RedemptionSettings( + redemptionStartDate=timestamp(now - timedelta(minutes=1)), + redemptionEndDate=timestamp(now + timedelta(days=30)), + ), + ) + ) + .id + ) + + base_offer = create_offer("Python Quickstart Offer", "Offer") + vip_offer = create_offer("Python Quickstart VIP Offer", "VIP Offer") + + print("Issuing and updating coupons") + email = self.config.recipient_email or "python.coupon@dummy.passkit.com" + + def issue(offer_id: str, external_id: str, name: str) -> tuple[str, Coupon]: + coupon = Coupon( + externalId=external_id, + campaignId=campaign_id, + offerId=offer_id, + person=Person(displayName=name, emailAddress=email), + ) + return self.service("coupons").createCoupon(coupon).id, coupon + + base_id, base_coupon = issue(base_offer, "python-coupon-base", "Coupon Holder") + vip_id, _ = issue(vip_offer, "python-coupon-vip", "VIP Coupon Holder") + base_coupon.id = base_id + base_coupon.person.displayName = "Updated Coupon Holder" + self.service("coupons").updateCoupon(base_coupon) + + print("Reading, listing, counting, redeeming, and voiding coupons") + self.service("coupons").getCouponById(Id(id=base_id)) + list( + self.service("coupons").listCouponsByCouponCampaign( + ListRequest(couponCampaignId=campaign_id, filters=Filters(limit=-1)) + ) + ) + self.service("coupons").countCouponsByCouponCampaign( + ListRequest(couponCampaignId=campaign_id, filters=Filters(limit=-1)) + ) + self.service("coupons").redeemCoupon( + Coupon(id=base_id, campaignId=campaign_id, offerId=base_offer) + ) + self.service("coupons").voidCoupon( + Coupon(id=vip_id, campaignId=campaign_id, offerId=vip_offer) + ) + + print(f"Created coupons: {base_id}, {vip_id}") + return {"baseCouponId": base_id, "vipCouponId": vip_id} diff --git a/quickstarts/workflows/event_tickets.py b/quickstarts/workflows/event_tickets.py new file mode 100644 index 0000000..9eef7de --- /dev/null +++ b/quickstarts/workflows/event_tickets.py @@ -0,0 +1,162 @@ +"""Event-ticket lifecycle example.""" + +from datetime import datetime, timedelta, timezone + +from passkit.io.common.common_objects_pb2 import Id +from passkit.io.common.filter_pb2 import Filters +from passkit.io.common.personal_pb2 import Person +from passkit.io.common.project_pb2 import ProjectStatus +from passkit.io.common.protocols_pb2 import PassProtocol +from passkit.io.event_tickets.event_pb2 import Event +from passkit.io.event_tickets.production_pb2 import Production +from passkit.io.event_tickets.ticket_pb2 import ( + IssueTicketRequest, + OrderNumberRequest, + RedeemTicketRequest, + Ticket, + TicketId, + TicketListRequest, + TicketNumberRequest, + ValidateTicketRequest, +) +from passkit.io.event_tickets.ticket_type_pb2 import TicketType +from passkit.io.event_tickets.venue_pb2 import Venue + +from quickstarts.workflow import Workflow, timestamp + + +class EventTicketsWorkflow(Workflow): + protocol = PassProtocol.EVENT_TICKETING + + def execute(self) -> dict[str, str]: + print("Creating event-ticket images and template") + self.create_images() + template_id = self.create_template("Python Quickstart Event Ticket", "#CD7F32") + + print("Creating production, venue, event, and ticket type") + production_id = ( + self.service("event_tickets") + .createProduction( + Production( + name="Python Quickstart Production", + finePrint="Quickstart demonstration only.", + status=[ + ProjectStatus.PROJECT_DRAFT, + ProjectStatus.PROJECT_ACTIVE_FOR_OBJECT_CREATION, + ], + ) + ) + .id + ) + self.remember( + "production", + lambda: self.service("event_tickets").deleteProduction(Production(id=production_id)), + ) + venue_id = ( + self.service("event_tickets") + .createVenue( + Venue( + name="Python Quickstart Venue", + address="123 Quickstart Street, London", + timezone="Europe/London", + ) + ) + .id + ) + self.remember( + "venue", + lambda: self.service("event_tickets").deleteVenue(Venue(id=venue_id)), + ) + + start = datetime.now(timezone.utc) + timedelta(days=7) + event_id = ( + self.service("event_tickets") + .createEvent( + Event( + production=Production(id=production_id, name="Python Quickstart Production"), + venue=Venue(id=venue_id, name="Python Quickstart Venue"), + doorsOpen=timestamp(start - timedelta(hours=1)), + scheduledStartDate=timestamp(start), + relevantDate=timestamp(start), + endDate=timestamp(start + timedelta(hours=3)), + ) + ) + .id + ) + self.remember( + "event", + lambda: self.service("event_tickets").deleteEvent(Event(id=event_id)), + ) + ticket_type_id = ( + self.service("event_tickets") + .createTicketType( + TicketType( + name="Python Quickstart General Admission", + productionId=production_id, + beforeRedeemPassTemplateId=template_id, + ) + ) + .id + ) + self.remember( + "ticket type", + lambda: self.service("event_tickets").deleteTicketType(TicketType(id=ticket_type_id)), + ) + + print("Issuing and updating ticket") + ticket_number = "PY-1001" + order_number = "PY-ORDER-1001" + ticket_id = ( + self.service("event_tickets") + .issueTicket( + IssueTicketRequest( + eventId=event_id, + ticketTypeId=ticket_type_id, + ticketNumber=ticket_number, + orderNumber=order_number, + person=Person( + displayName="Python Ticket Holder", + emailAddress=self.config.recipient_email + or "python.ticket@dummy.passkit.com", + ), + ) + ) + .id + ) + self.remember( + "ticket", + lambda: self.service("event_tickets").deleteTicket(TicketId(ticketId=ticket_id)), + ) + self.service("event_tickets").updateTicket( + Ticket(id=ticket_id, person=Person(displayName="Updated Ticket Holder")) + ) + + print("Reading, listing, validating, and redeeming ticket") + self.service("event_tickets").getTicketById(Id(id=ticket_id)) + self.service("event_tickets").getTicketByTicketNumber( + TicketNumberRequest(productionId=production_id, ticketNumber=ticket_number) + ) + self.service("event_tickets").getTicketsByOrderNumber( + OrderNumberRequest(productionId=production_id, orderNumber=order_number) + ) + list( + self.service("event_tickets").listTickets( + TicketListRequest( + productionId=production_id, + eventId=event_id, + filters=Filters(limit=-1), + ) + ) + ) + self.service("event_tickets").countTickets( + TicketListRequest(productionId=production_id, eventId=event_id) + ) + self.service("event_tickets").validateTicket( + ValidateTicketRequest(maxNumberOfValidations=3, ticket=TicketId(ticketId=ticket_id)) + ) + self.service("event_tickets").redeemTicket( + RedeemTicketRequest(ticket=TicketId(ticketId=ticket_id)) + ) + + print(f"Created event ticket: {ticket_id}") + return {"eventTicketId": ticket_id} diff --git a/quickstarts/workflows/flights.py b/quickstarts/workflows/flights.py new file mode 100644 index 0000000..9abc1f3 --- /dev/null +++ b/quickstarts/workflows/flights.py @@ -0,0 +1,196 @@ +"""Flight boarding-pass lifecycle example.""" + +from datetime import date, datetime, timedelta, timezone +from random import randint + +import grpc +from passkit.io.common.common_objects_pb2 import Date, Id, LocalDateTime, Time +from passkit.io.common.personal_pb2 import Person +from passkit.io.common.protocols_pb2 import PassProtocol +from passkit.io.flights.airport_pb2 import AirportCode, Port +from passkit.io.flights.boarding_pass_pb2 import ( + BoardingPassRecord, + BoardingPassRecordRequest, +) +from passkit.io.flights.carrier_pb2 import Carrier, CarrierCode +from passkit.io.flights.flight_designator_pb2 import ( + FlightDesignator, + FlightDesignatorRequest, +) +from passkit.io.flights.flight_pb2 import Flight, FlightRequest +from passkit.io.flights.passenger_pb2 import Passenger + +from quickstarts.workflow import Workflow + + +def passkit_date(value: date) -> Date: + return Date(year=value.year, month=value.month, day=value.day) + + +def local_time(value: datetime) -> LocalDateTime: + return LocalDateTime(dateTime=value.strftime("%Y-%m-%dT%H:%M:%S")) + + +class FlightsWorkflow(Workflow): + protocol = PassProtocol.FLIGHT_PROTOCOL + + def execute(self) -> dict[str, str]: + print("Creating flight images and template") + self.create_images() + template_id = self.create_template("Python Quickstart Flight", "#FFEA6C") + carrier = self.config.carrier_code + origin = self.config.origin + destination = self.config.destination + flight_number = str(randint(100, 999)) + departure = datetime.now(timezone.utc) + timedelta(days=7) + departure_date = passkit_date(departure.date()) + + print("Creating or reusing carrier and airports") + carrier_created = self._create_or_reuse_carrier(carrier) + if carrier_created: + self.remember( + f"carrier {carrier}", + lambda: self.service("flights").deleteCarrier(CarrierCode(carrierCode=carrier)), + ) + for code, created in ( + ( + origin, + self._create_or_reuse_port(origin, "YYYY", "Origin", "Europe/London"), + ), + ( + destination, + self._create_or_reuse_port(destination, "VHHH", "Destination", "Asia/Hong_Kong"), + ), + ): + if created: + self.remember( + f"airport {code}", + lambda value=code: self.service("flights").deletePort( + AirportCode(airportCode=value) + ), + ) + + print("Creating flight and flight designator") + request = FlightRequest( + carrierCode=carrier, + flightNumber=flight_number, + boardingPoint=origin, + deplaningPoint=destination, + departureDate=departure_date, + ) + self.service("flights").createFlight( + Flight( + carrierCode=carrier, + flightNumber=flight_number, + boardingPoint=origin, + deplaningPoint=destination, + departureDate=departure_date, + scheduledDepartureTime=local_time(departure), + boardingTime=local_time(departure - timedelta(minutes=45)), + gateClosingTime=local_time(departure - timedelta(minutes=15)), + scheduledArrivalTime=local_time(departure + timedelta(hours=8)), + passTemplateId=template_id, + ) + ) + self.remember("flight", lambda: self.service("flights").deleteFlight(request)) + + times = { + "scheduledDepartureTime": Time(hour=13), + "boardingTime": Time(hour=12, minute=15), + "gateClosingTime": Time(hour=12, minute=45), + "scheduledArrivalTime": Time(hour=21), + } + designator = FlightDesignator( + carrierCode=carrier, + flightNumber=flight_number, + revision=1, + active=True, + origin=origin, + destination=destination, + passTemplateId=template_id, + ) + for day in ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"): + getattr(designator.schedule, day).CopyFrom( + getattr(designator.schedule, day).__class__(**times) + ) + self.service("flights").createFlightDesignator(designator) + designator_request = FlightDesignatorRequest( + carrierCode=carrier, flightNumber=flight_number, revision=1 + ) + self.remember( + "flight designator", + lambda: self.service("flights").deleteFlightDesignator(designator_request), + ) + self.service("flights").getFlight(request) + self.service("flights").getFlightDesignator(designator_request) + + print("Creating and reading boarding pass") + response = self.service("flights").createBoardingPass( + BoardingPassRecord( + operatingCarrierPNR=f"PY{flight_number}", + boardingPoint=origin, + deplaningPoint=destination, + carrierCode=carrier, + flightNumber=flight_number, + departureDate=departure_date, + passenger=Passenger( + passengerDetails=Person( + displayName="Python Passenger", + forename="Python", + surname="Passenger", + emailAddress=self.config.recipient_email + or "python.flight@dummy.passkit.com", + ) + ), + sequenceNumber=123, + seatNumber="12A", + **{"class": "Economy"}, + ) + ) + boarding_pass = response.boardingPasses[0] + boarding_request = BoardingPassRecordRequest(passId=Id(id=boarding_pass.id)) + self.remember( + "boarding pass", + lambda: self.service("flights").deleteBoardingPass(boarding_request), + ) + self.service("flights").getBoardingPassRecord(boarding_request) + + print(f"Created boarding pass: {boarding_pass.id}") + return {"boardingPassId": boarding_pass.id, "boardingPassUrl": boarding_pass.url} + + def _create_or_reuse_carrier(self, code: str) -> bool: + try: + self.service("flights").createCarrier( + Carrier( + airlineName="Python Quickstart Airline", + iataCarrierCode=code, + passTypeIdentifier=self.config.apple_certificate, + ) + ) + return True + except grpc.RpcError as error: + if error.code() != grpc.StatusCode.ALREADY_EXISTS: + raise + self.service("flights").getCarrier(CarrierCode(carrierCode=code)) + print(f"Carrier {code} already exists; reusing it.") + return False + + def _create_or_reuse_port(self, code: str, icao: str, city: str, timezone_name: str) -> bool: + try: + self.service("flights").createPort( + Port( + iataAirportCode=code, + icaoAirportCode=icao, + airportName=f"Python Quickstart {city} Airport", + cityName=city, + countryCode="GB" if city == "Origin" else "HK", + timezone=timezone_name, + ) + ) + return True + except grpc.RpcError as error: + if error.code() != grpc.StatusCode.ALREADY_EXISTS: + raise + self.service("flights").getPort(AirportCode(airportCode=code)) + print(f"Airport {code} already exists; reusing it.") + return False diff --git a/quickstarts/workflows/membership.py b/quickstarts/workflows/membership.py new file mode 100644 index 0000000..de759fd --- /dev/null +++ b/quickstarts/workflows/membership.py @@ -0,0 +1,154 @@ +"""Membership and loyalty lifecycle example.""" + +from passkit.io.common.common_objects_pb2 import Id +from passkit.io.common.filter_pb2 import FieldFilter, FilterGroup, Filters, Operator +from passkit.io.common.personal_pb2 import Person +from passkit.io.common.project_pb2 import ProjectStatus +from passkit.io.common.protocols_pb2 import PassProtocol +from passkit.io.member.member_pb2 import ( + EarnBurnPointsRequest, + ListRequest, + Member, + MemberCheckInOutRequest, + MemberRecordByExternalIdRequest, +) +from passkit.io.member.program_pb2 import BalanceType, PointsType, Program +from passkit.io.member.tier_pb2 import Tier + +from quickstarts.workflow import Workflow + + +class MembershipWorkflow(Workflow): + protocol = PassProtocol.MEMBERSHIP + + def execute(self) -> dict[str, str]: + print("Creating membership images and templates") + self.create_images() + bronze_template = self.create_template("Python Quickstart Bronze", "#CD7F32") + silver_template = self.create_template("Python Quickstart Silver", "#C0C0C0") + + print("Creating membership program and tiers") + program = Program( + name="Python Quickstart Loyalty Program", + status=[ + ProjectStatus.PROJECT_DRAFT, + ProjectStatus.PROJECT_ACTIVE_FOR_OBJECT_CREATION, + ], + pointsType=PointsType(balanceType=BalanceType.BALANCE_TYPE_INT), + ) + program_id = self.service("members").createProgram(program).id + self.remember( + "membership program", + lambda: self.service("members").deleteProgram(Id(id=program_id)), + ) + + bronze_tier = ( + self.service("members") + .createTier( + Tier( + id="bronze", + name="Bronze", + tierIndex=1, + programId=program_id, + passTemplateId=bronze_template, + timezone="Europe/London", + ) + ) + .id + ) + silver_tier = ( + self.service("members") + .createTier( + Tier( + id="silver", + name="Silver", + tierIndex=2, + programId=program_id, + passTemplateId=silver_template, + timezone="Europe/London", + ) + ) + .id + ) + + print("Enrolling and updating members") + email = self.config.recipient_email or "python.member@dummy.passkit.com" + bronze_external_id = "python-quickstart-bronze" + bronze = Member( + programId=program_id, + tierId=bronze_tier, + externalId=bronze_external_id, + person=Person( + displayName="Bronze Member", + forename="Bronze", + surname="Member", + emailAddress=email, + ), + ) + bronze_id = self.service("members").enrolMember(bronze).id + silver_id = ( + self.service("members") + .enrolMember( + Member( + programId=program_id, + tierId=silver_tier, + externalId="python-quickstart-silver", + person=Person( + displayName="Silver Member", + forename="Silver", + surname="Member", + emailAddress=email, + ), + ) + ) + .id + ) + + self.service("members").checkInMember( + MemberCheckInOutRequest( + memberId=bronze_id, + address="London, UK", + lat=51.5074, + lon=-0.1278, + ) + ) + self.service("members").checkOutMember( + MemberCheckInOutRequest(memberId=bronze_id, address="London, UK") + ) + self.service("members").earnPoints( + EarnBurnPointsRequest(id=bronze_id, programId=program_id, points=100) + ) + self.service("members").burnPoints( + EarnBurnPointsRequest(id=bronze_id, programId=program_id, points=25) + ) + bronze.id = bronze_id + bronze.person.displayName = "Updated Bronze Member" + self.service("members").updateMember(bronze) + + print("Reading, listing, and counting members") + self.service("members").getMemberRecordById(Id(id=bronze_id)) + self.service("members").getMemberRecordByExternalId( + MemberRecordByExternalIdRequest(programId=program_id, externalId=bronze_external_id) + ) + filters = Filters( + limit=-1, + filterGroups=[ + FilterGroup( + condition=Operator.AND, + fieldFilters=[ + FieldFilter( + filterField="passStatus", + filterValue="PASS_ISSUED", + filterOperator="eq", + ) + ], + ) + ], + ) + request = ListRequest(programId=program_id, filters=filters) + list(self.service("members").listMembers(request)) + self.service("members").countMembers(request) + list(self.service("members").listEventsForMember(Id(id=bronze_id))) + + print(f"Created membership passes: {bronze_id}, {silver_id}") + return {"bronzePassId": bronze_id, "silverPassId": silver_id} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b4ed380 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +cryptography>=50.0.1,<51 +passkit-python-grpc-sdk==1.1.162 +python-dotenv>=1.1,<2 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..5ec43c0 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,28 @@ +import unittest + +from quickstarts.api import DESTRUCTIVE_METHODS, PassKitApi, Service + + +class FakePool: + def service(self, _name): + return type("Stub", (), {"safe": lambda self, request: request})() + + +class ApiTests(unittest.TestCase): + def test_operations_exposes_every_service(self): + operations = PassKitApi.operations() + self.assertGreaterEqual(len(operations), 14) + self.assertIn("createProgram", operations["members"]) + self.assertIn("issueTicket", operations["event_tickets"]) + self.assertIn("createBoardingPass", operations["flights"]) + self.assertGreater(sum(map(len, operations.values())), 200) + + def test_sensitive_methods_are_blocked_by_default(self): + service = Service(FakePool(), "members", False) + method = next(iter(DESTRUCTIVE_METHODS)) + with self.assertRaises(PermissionError): + getattr(service, method)(object()) + + def test_safe_method_is_forwarded(self): + marker = object() + self.assertIs(Service(FakePool(), "members", False).safe(marker), marker) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..5b568c1 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,44 @@ +import os +import tempfile +import unittest +from dataclasses import replace +from pathlib import Path +from unittest.mock import patch + +from quickstarts.config import Config + + +class ConfigTests(unittest.TestCase): + def test_defaults_are_developer_friendly(self): + with patch.dict(os.environ, {}, clear=True): + config = Config.load() + self.assertEqual(config.address, "grpc.pub1.passkit.io:443") + self.assertEqual(config.connection_mode, "pool") + self.assertEqual(config.pool_size, 5) + self.assertFalse(config.keep_assets) + + def test_validation_reports_all_missing_files(self): + with tempfile.TemporaryDirectory() as directory: + missing = Path(directory) / "missing.pem" + config = Config( + address="grpc.pub1.passkit.io:443", + root_certificate=missing, + private_key=missing, + certificate=missing, + passphrase=None, + connection_mode="pool", + pool_size=5, + keep_assets=False, + recipient_email=None, + apple_certificate=None, + carrier_code="YY", + origin="YY4", + destination="ADP", + ) + with self.assertRaisesRegex(ValueError, "Credential file not found"): + config.validate() + + def test_flights_require_apple_certificate(self): + config = replace(Config.load(), apple_certificate=None) + with self.assertRaisesRegex(ValueError, "PASSKIT_APPLE_CERTIFICATE"): + config.validate(require_flights=True, check_files=False) diff --git a/tests/test_event_ticket_methods.py b/tests/test_event_ticket_methods.py new file mode 100644 index 0000000..c3138ef --- /dev/null +++ b/tests/test_event_ticket_methods.py @@ -0,0 +1,29 @@ +import unittest + +import event_tickets + + +class EventTicketMethodsTests(unittest.TestCase): + def test_guided_event_ticket_methods_are_exported(self): + expected = { + "count_tickets", + "create_event", + "create_production", + "create_ticket_type", + "create_venue", + "delete_event", + "delete_production", + "delete_ticket", + "delete_ticket_type", + "delete_venue", + "get_ticket_by_id", + "get_ticket_by_number", + "get_tickets_by_order_number", + "issue_ticket", + "list_tickets", + "redeem_ticket", + "update_ticket", + "validate_ticket", + } + self.assertTrue(expected.issubset(event_tickets.__all__)) + self.assertTrue(all(callable(getattr(event_tickets, name)) for name in expected)) diff --git a/tests/test_product_methods.py b/tests/test_product_methods.py new file mode 100644 index 0000000..0f6d0ae --- /dev/null +++ b/tests/test_product_methods.py @@ -0,0 +1,14 @@ +import unittest + +import coupons +import event_tickets +import flights +import membership + + +class ProductMethodsTests(unittest.TestCase): + def test_each_product_exports_callable_methods(self): + for product in (membership, coupons, event_tickets, flights): + with self.subTest(product=product.__name__): + self.assertTrue(product.__all__) + self.assertTrue(all(callable(getattr(product, name)) for name in product.__all__)) diff --git a/tests/test_workflows.py b/tests/test_workflows.py new file mode 100644 index 0000000..d19acdd --- /dev/null +++ b/tests/test_workflows.py @@ -0,0 +1,138 @@ +import tempfile +import unittest +from pathlib import Path + +from passkit.io.common.common_objects_pb2 import Id, PassBundle +from passkit.io.common.template_pb2 import PassTemplate +from passkit.io.flights.boarding_pass_pb2 import BoardingPassesResponse +from passkit.io.image.image_pb2 import ImageIds + +from quickstarts.config import Config +from quickstarts.workflows.coupons import CouponsWorkflow +from quickstarts.workflows.event_tickets import EventTicketsWorkflow +from quickstarts.workflows.flights import FlightsWorkflow +from quickstarts.workflows.membership import MembershipWorkflow + + +class FakeStub: + def __init__(self, calls): + self.calls = calls + self.counter = 0 + + def __getattr__(self, method): + def call(request): + self.calls.append((method, request)) + self.counter += 1 + if method == "createImages": + return ImageIds( + icon="icon", + logo="logo", + appleLogo="apple", + hero="hero", + eventStrip="event-strip", + strip="strip", + ) + if method == "getDefaultTemplate": + return PassTemplate() + if method == "createBoardingPass": + return BoardingPassesResponse( + boardingPasses=[PassBundle(id="boarding-pass", url="https://example.test/pass")] + ) + if method.startswith("list"): + return iter(()) + return Id(id=f"id-{self.counter}") + + return call + + +class FakePool: + def __init__(self): + self.calls = [] + self.stubs = {} + + def service(self, name): + return self.stubs.setdefault(name, FakeStub(self.calls)) + + +def config() -> Config: + missing = Path(tempfile.gettempdir()) / "unused.pem" + return Config( + address="grpc.pub1.passkit.io:443", + root_certificate=missing, + private_key=missing, + certificate=missing, + passphrase=None, + connection_mode="pool", + pool_size=2, + keep_assets=False, + recipient_email=None, + apple_certificate="pass.com.example.airline", + carrier_code="YY", + origin="YY4", + destination="ADP", + ) + + +class WorkflowTests(unittest.TestCase): + def exercise(self, workflow_class, required_methods): + pool = FakePool() + result = workflow_class(pool, config()).execute() + called = {method for method, _request in pool.calls} + self.assertTrue(required_methods <= called) + self.assertTrue(result) + + def test_membership_lifecycle(self): + self.exercise( + MembershipWorkflow, + { + "createProgram", + "createTier", + "enrolMember", + "earnPoints", + "burnPoints", + "listMembers", + }, + ) + + def test_coupon_lifecycle(self): + pool = FakePool() + result = CouponsWorkflow(pool, config()).execute() + called = {method for method, _request in pool.calls} + self.assertTrue( + { + "createCouponCampaign", + "createCouponOffer", + "createCoupon", + "redeemCoupon", + "voidCoupon", + } + <= called + ) + offers = [request for method, request in pool.calls if method == "createCouponOffer"] + self.assertTrue(all(len(offer.offerShortTitle) <= 10 for offer in offers)) + self.assertTrue(result) + + def test_event_ticket_lifecycle(self): + self.exercise( + EventTicketsWorkflow, + { + "createProduction", + "createVenue", + "createEvent", + "createTicketType", + "issueTicket", + "redeemTicket", + }, + ) + + def test_flight_lifecycle(self): + self.exercise( + FlightsWorkflow, + { + "createCarrier", + "createPort", + "createFlight", + "createFlightDesignator", + "createBoardingPass", + }, + )