Metadata-Version: 2.5
Name: kloship-utils
Version: 1.2.0
Summary: Helper library for KloShip.
Project-URL: Homepage, https://www.klocel.com
Author-email: Guillaume Candau <guillaume.candau@klocel.com>
License-Expression: MIT
License-File: LICENSE
Keywords: aws-lambda,edi,klocel,kloship,shipping,zpl
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: jinja2>=3.1
Requires-Dist: requests>=2.31
Requires-Dist: unidecode>=1.3
Provides-Extra: all
Requires-Dist: boto3>=1.28; extra == 'all'
Requires-Dist: paramiko>=3.4; extra == 'all'
Provides-Extra: aws
Requires-Dist: boto3>=1.28; extra == 'aws'
Provides-Extra: dev
Requires-Dist: boto3-stubs[dynamodb,s3]>=1.28; extra == 'dev'
Requires-Dist: boto3>=1.28; extra == 'dev'
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: moto[dynamodb,s3]>=5.0; extra == 'dev'
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: paramiko>=3.4; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: responses>=0.25; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Requires-Dist: twine>=5.1; extra == 'dev'
Requires-Dist: types-paramiko>=3.4; extra == 'dev'
Requires-Dist: types-requests>=2.31; extra == 'dev'
Provides-Extra: sftp
Requires-Dist: paramiko>=3.4; extra == 'sftp'
Description-Content-Type: text/markdown

# kloship-utils

`kloship-utils` is a helper library for [KloShip](https://www.klocel.com).

## Installation

Requires Python 3.11 or later.

```bash
pip install kloship-utils            # core: encoding, units, text, templates, xml, handle units, CELLA, FTP
pip install "kloship-utils[sftp]"    # + SFTP uploads (paramiko)
pip install "kloship-utils[aws]"     # + boto3, for the S3 / DynamoDB helpers outside AWS Lambda
```

`boto3` is not a mandatory dependency: the AWS Lambda Python runtime already provides it.

### In an AWS Lambda layer

Add the package to the layer `requirements.txt`, pinned to an exact version:

```text
kloship-utils[sftp]==1.2.0
```

Every dependency ships `aarch64` wheels, so the package works with `arm64` Lambdas.

## Modules

| Module | Content |
|---|---|
| `kloship_utils.encoding` | `to_base64_gzip_json`, `from_base64_gzip_json`: payloads exchanged between Step Functions and Lambdas |
| `kloship_utils.helpers` | `to_list`, `empty_if_none`, `chunks`, `get_path` |
| `kloship_utils.units` | `convert_SI` (length, volume, weight, liquid, dates), `convert_date`, `convert_values`, `to_decimal`, `to_minor_units`, `round_up` |
| `kloship_utils.text` | `strip_diacritics`, `strip_diacritics_in`, `replace_special_chars` |
| `kloship_utils.templates` | `render_template`, `render_zpl_label` (Jinja2) |
| `kloship_utils.zpl` | `replace_zpl_commands`, `build_zpl_label`, `process_zpl_label`, `split_zpl` |
| `kloship_utils.xml` | `dict_to_xml`, `to_xml_string` (attributes, lists, CDATA) |
| `kloship_utils.handle_units` | `Status`, `global_group_id`, `global_group_id2`, `group_by_delivery`, `next_index_in_order`, `delete_attachments_from_result`, `clear_fields`, `get_additional_parameters`, `get_additional_parameter_value`, `find_additional_parameter_value`, `parse_ks`, `ks_get`, `is_empty_address` |
| `kloship_utils.invoices` | `resolve_invoice`, `invoice_lines`, `ks_invoice_lines`, `invoice_total`, `customs_importer_address`, `weight_in_kg`, `InvoiceLine` |
| `kloship_utils.customs` | `customs_zone`, `requires_customs`, `resolve_party`, `Party`, `PartySource` |
| `kloship_utils.fixed_width` | `Field`, `format_record`, `format_field`, `parse_record`, `record_length` |
| `kloship_utils.aws.s3` | `build_key`, `put_file`, `get_file`, `save_label`, `save_shipment_file`, file name helpers |
| `kloship_utils.aws.dynamodb` | HandleUnit lookups, `increment_counter`, `get_parameter`, `get_carrier_account_config`, `get_conversion`, `get_packaging_conversion`, `execute_statement_all`, `batch_execute` |
| `kloship_utils.cella` | `CellaClient`, `get_rule_result`, `get_rule_result_from_kloship_front`, `pdf_to_zpl` |
| `kloship_utils.transfers` | `TransferConfig`, `TunnelConfig`, `upload_ftp`, `upload_sftp`, `load_private_key` |
| `kloship_utils.exceptions` | `KloshipUtilsError` and its subclasses |

### Encoding

```python
from kloship_utils.encoding import from_base64_gzip_json, to_base64_gzip_json

def lambda_handler(event, context):
    handle_units = from_base64_gzip_json(event["compressed_handle_units"])
    ...
    return {"compressed_handle_units": to_base64_gzip_json(enriched_handle_units)}
```

Pass `default=str` to `to_base64_gzip_json` to serialize the `Decimal` values returned by DynamoDB.

### Units and dates

```python
from kloship_utils.units import convert_SI, convert_date

convert_SI(hu["WEIGHT"]["#text"], "weight", hu["WEIGHT"]["@format"], "kg")           # "10.05" (2 decimals)
convert_SI("1234", "length", "mm", "cm", number_decimal=0)                         # "123"
convert_SI("1234", "length", "mm", "m", number_decimal=None)                       # "1.234" (no rounding)
convert_SI("2024/01/31", "date", "YYYY/MM/DD", "%d.%m.%Y")                          # "31.01.2024"
convert_date("2024-01-31 08:05:00", "YYYY-MM-DD HH:MI:SS", "CCYYMMDDHHMM")          # "202401310805"
```

Date formats accept KloShip tokens (`yyyy`, `ccyy`, `yy`, `mm`, `dd`, `hh`, `mi`/`ii`, `ss`; `mm` after `hh` means
minutes) or `strftime` patterns.

### Labels

```python
from kloship_utils.templates import render_zpl_label
from kloship_utils.zpl import split_zpl
from kloship_utils.aws.s3 import save_label

zpl = render_zpl_label(handle_unit, config, "layout_label.zpl")   # templates/ directory of the Lambda
handle_unit["label"] = split_zpl(zpl)                             # {"header", "body", "footer"}
handle_unit["KLOSHIP_LABEL_FILENAME"] = save_label(
    handle_unit, account_id, config, zpl, extension="zpl", content_type="x-application/zpl", carrier="ACME"
)
```

### ZPL labels

```python
from kloship_utils.zpl import build_zpl_label, process_zpl_label, replace_zpl_commands

# Everything driven by the CreateLabel configuration:
#   ReplaceZplCommand        [{"OriginalCommand": "\\^PON", "FinalCommand": ""}]  (regex replacements)
#   AdditionalKlocelZplInfos ZPL added at the end of the label content, before ^XZ
#   ZplCustomSplit, ZplSplitLabelPositionStart / Stop: ^XA / ^XZ delimiting the label in multi-format ZPL
handle_unit["label"], handle_unit["raw_label"] = process_zpl_label(zpl, label_config)

# Or step by step
zpl = replace_zpl_commands(zpl, [{"OriginalCommand": "\\^PON", "FinalCommand": ""}])
parts, raw = build_zpl_label(zpl, "^FO10,900^FDKlocel^FS")      # parts: {"header", "body", "footer"}
```

`build_zpl_label` splits the label around its first `^XA` and last `^XZ` (or the ones given by `label_start` /
`label_stop`), keeps any prefix and trailing content, and falls back to a line based split when the commands are not
found.

### S3

```python
from kloship_utils.aws.s3 import save_shipment_file, shipment_file_name

file_name = shipment_file_name(config, "edi")             # 20240131_080509_42.edi
save_shipment_file(
    edi, account_id, config, file_name,
    content_type="application/EDI-EDIFACT", carrier="ACME",
    encoding="ISO-8859-1", content_encoding="ISO-8859-1",
)
```

Keys follow the KloShip layout `{account}/{@senderCodeTDM}/{@serviceTagTDM}/{YYYY}/{MM}/{DD}/{file}`, in the
`STANDARD_IA` storage class. Buckets default to the `LABELS_BUCKET` / `SHIPMENT_BUCKET` environment variables.

### DynamoDB

```python
from kloship_utils.aws import dynamodb

handle_units = dynamodb.get_handle_units_by_global_group_id(account_id, config)       # PRE-SHIPPED / SHIPPED HUs
same_order = dynamodb.get_handle_units_by_delivery_number(                             # parcels of a shipmentId
    account_id, hu["DELIVERY"]["@deliveryNumber"], config["@senderCodeTDM"], service_code=config["@serviceTagTDM"]
)
number = dynamodb.increment_counter(config, parameter_name=config.get("ParameterNameHUCounter"))
packaging = dynamodb.get_packaging_conversion(hu["PACKAGING"], config, default=config.get("DefaultPackaging"))
```

Table names default to the standard environment variables (`HANDLE_UNIT_TABLE_NAME`,
`CARRIER_ACCOUNT_CONFIG_TABLE_NAME`, `PARAMETERS_TABLE_NAME`, `CONVERSIONS_TABLE_NAME`) and can be given explicitly.
All PartiQL statements are parameterized.

### CELLA rules

```python
from kloship_utils.cella import get_rule_result

values = get_rule_result(
    config["CreateLabel"]["FrontendConversion"],     # Url, WarehouseWorkerUsername, WarehouseWorkerPwd, WarehouseId, StockOwnerId
    rule_config,                                     # Name + Exit [{name, type, defaultValue}]
    {"packagingDescription": hu["PACKAGING"]},
)
```

### FTP / SFTP

```python
from kloship_utils.transfers import TransferConfig, TunnelConfig, upload_sftp

sftp = TransferConfig.from_carrier_config(config["Shipment"]["Sftp"])
tunnel = TunnelConfig.from_carrier_config(config["Shipment"]["TunnelSSH"], password_base64=True)
upload_sftp(sftp, file_name, edi, tunnel=tunnel)      # raises TransferError (with .code 401/402/404) on failure
```

`TransferConfig.from_carrier_config` reads the usual configuration keys whatever their case (`Host`, `Port`, `Login`,
`Password`, `RemotePath`, `EDIFileEncoding`, `UseCnOptsHostkeys`, `ParamikoKeyData`, `ParamikoSignVerifySSHData`,
`PrivateKey`, `PrivateKeyPassphrase`). With a `PrivateKey` (RSA, ECDSA or Ed25519, PEM or OpenSSH text), the SFTP
client and the SSH jump host authenticate with the key. `temporary_name` uploads the file under a temporary name and
renames it once complete:

```python
upload_sftp(sftp, "manifest.dat", content, temporary_name="manifest.lck")
```

### Invoices and customs

```python
from kloship_utils.customs import requires_customs, resolve_party
from kloship_utils.invoices import invoice_lines, resolve_invoice
from kloship_utils.units import to_minor_units

invoice = resolve_invoice(delivery, handle_units)          # shipment declaration first, else the latest HU invoice
lines = invoice_lines(invoice)                             # InvoiceLine(quantity, unit_price: Decimal, hs_code...)
pence = to_minor_units(lines[0].unit_price)                # rounded half up, never truncated

shipper = resolve_party(hu["DELIVERY"]["SHIPPER"], config["ACME"]["Parties"].get("Shipper"))   # API or config
if requires_customs(origin_country, destination_country, destination_postal_code=postal_code):
    ...                                                   # e.g. GB to Northern Ireland (BT postcodes)
```

### Fixed-width records

```python
from kloship_utils.fixed_width import Field, format_record

HEADER = [Field("Type", 10), Field("Version", 10), Field("Records", 10, "N", align="left")]
format_record(HEADER, {"Type": "HEADER", "Version": "1.00", "Records": 8})   # values cut / padded to their field
```

## Versioning

The package follows [Semantic Versioning](https://semver.org). Pin an exact version in your requirements.

## License

MIT License, © Groupe Klocel.
