laytime 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- laytime-0.1.0/.github/workflows/ci.yml +23 -0
- laytime-0.1.0/.gitignore +9 -0
- laytime-0.1.0/CHANGELOG.md +14 -0
- laytime-0.1.0/LICENSE +21 -0
- laytime-0.1.0/PKG-INFO +187 -0
- laytime-0.1.0/README.md +164 -0
- laytime-0.1.0/pyproject.toml +50 -0
- laytime-0.1.0/src/laytime/__init__.py +12 -0
- laytime-0.1.0/src/laytime/__main__.py +5 -0
- laytime-0.1.0/src/laytime/cli.py +42 -0
- laytime-0.1.0/src/laytime/engine.py +290 -0
- laytime-0.1.0/src/laytime/py.typed +0 -0
- laytime-0.1.0/tests/test_cli.py +65 -0
- laytime-0.1.0/tests/test_golden.py +245 -0
- laytime-0.1.0/uv.lock +108 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.12", "3.13"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: astral-sh/setup-uv@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- run: uv sync --group dev
|
|
20
|
+
- run: uv run ruff check .
|
|
21
|
+
- run: uv run ruff format --check .
|
|
22
|
+
- run: uv run pytest -q
|
|
23
|
+
- run: uv build
|
laytime-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-09-03
|
|
4
|
+
|
|
5
|
+
First public release. Extracted unchanged from the laytime engine that has run
|
|
6
|
+
inside [MarOps](https://zdeck.tech/case-study-marops.html) since Phase 0.
|
|
7
|
+
|
|
8
|
+
- `compute_laytime(basis)`: SHINC / SHEX / WWD, `unless used`, holidays,
|
|
9
|
+
NOR-to-start offset, fixed days or cargo ÷ rate allowance, explicit
|
|
10
|
+
interruption records, once-on-demurrage with charter-party override,
|
|
11
|
+
half or full despatch on working time saved or all time saved,
|
|
12
|
+
reversible or per-port laytime.
|
|
13
|
+
- `laytime` command line: JSON basis in, JSON result out.
|
|
14
|
+
- Golden test suite with hand-verified expected values.
|
laytime-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ali Oktay
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
laytime-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: laytime
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: GENCON-style laytime, demurrage and despatch calculator for dry-bulk voyage charters
|
|
5
|
+
Project-URL: Homepage, https://github.com/oktaybobus/laytime
|
|
6
|
+
Project-URL: Issues, https://github.com/oktaybobus/laytime/issues
|
|
7
|
+
Project-URL: Case study, https://zdeck.tech/case-study-marops.html
|
|
8
|
+
Author: Ali Oktay
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: charter party,demurrage,despatch,gencon,laytime,maritime,shipping
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Other Audience
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Office/Business
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# laytime
|
|
25
|
+
|
|
26
|
+
GENCON-style laytime, demurrage and despatch calculator for dry-bulk voyage
|
|
27
|
+
charters. One pure function, no dependencies, no floats.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from laytime import compute_laytime
|
|
31
|
+
|
|
32
|
+
result = compute_laytime(basis)
|
|
33
|
+
result["net_amount"] # "8000.00" (positive: demurrage due; negative: despatch due)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The engine was extracted unchanged from [MarOps](https://zdeck.tech/case-study-marops.html),
|
|
37
|
+
a back-office system for small shipowners, where it has produced every laytime
|
|
38
|
+
statement since the first release. It is published so that the calculation can
|
|
39
|
+
be read, checked and re-run by anyone who receives one of those statements.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install laytime
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Python 3.12 or newer. No runtime dependencies.
|
|
48
|
+
|
|
49
|
+
## Command line
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
laytime basis.json # pretty JSON result
|
|
53
|
+
laytime basis.json --compact # one line
|
|
54
|
+
cat basis.json | laytime - # from stdin
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Exit code 1 and a message naming the missing field when the basis is
|
|
58
|
+
incomplete; the engine never guesses a value.
|
|
59
|
+
|
|
60
|
+
## The basis
|
|
61
|
+
|
|
62
|
+
Everything the calculation needs is in one JSON document. Store it next to the
|
|
63
|
+
result: replaying the same basis gives the same result, which is what you want
|
|
64
|
+
when the claim is disputed months later.
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"cp": {
|
|
69
|
+
"laytime_basis": "SHEX",
|
|
70
|
+
"unless_used": false,
|
|
71
|
+
"reversible": false,
|
|
72
|
+
"nor_to_start_hours": 12,
|
|
73
|
+
"holidays": ["2026-03-03"],
|
|
74
|
+
"demurrage_rate_per_day": "8000",
|
|
75
|
+
"currency": "USD",
|
|
76
|
+
"despatch_fraction": "half",
|
|
77
|
+
"despatch_on": "working_time_saved",
|
|
78
|
+
"interruptions_count_on_demurrage": false
|
|
79
|
+
},
|
|
80
|
+
"ports": [
|
|
81
|
+
{
|
|
82
|
+
"name": "Samsun",
|
|
83
|
+
"purpose": "loading",
|
|
84
|
+
"nor_tendered": "2026-03-01T06:00",
|
|
85
|
+
"nor_accepted": "2026-03-01T06:00",
|
|
86
|
+
"completed_at": "2026-03-06T06:00",
|
|
87
|
+
"allowed": {"kind": "rate", "cargo_qty": "6000", "rate_per_day": "2000"},
|
|
88
|
+
"interruptions": [
|
|
89
|
+
{"from": "2026-03-02T06:00", "to": "2026-03-02T18:00", "reason": "rain"}
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### `cp` — charter-party terms
|
|
97
|
+
|
|
98
|
+
| Field | Values | Meaning |
|
|
99
|
+
|---|---|---|
|
|
100
|
+
| `laytime_basis` | `SHINC`, `SHEX`, `WWD` | `SHINC` counts every hour. `SHEX` and `WWD` skip Sundays and `holidays`. Weather stoppages are explicit interruptions, so `WWD` behaves like `SHEX` inside the engine. |
|
|
101
|
+
| `unless_used` | bool | Excepted days count anyway; idle time on them is deducted through interruptions. |
|
|
102
|
+
| `reversible` | bool | Pool the allowance of all ports into one ledger (time saved at loading carries to discharge) instead of settling each port on its own. |
|
|
103
|
+
| `nor_to_start_hours` | int | Hours between NOR acceptance and the start of laytime. |
|
|
104
|
+
| `holidays` | list of `YYYY-MM-DD` | Local-calendar holidays treated like Sundays. |
|
|
105
|
+
| `demurrage_rate_per_day` | decimal string | Per day, pro rata. |
|
|
106
|
+
| `currency` | string | Echoed into the result. |
|
|
107
|
+
| `despatch_fraction` | `half`, `full` | Half despatch is the dry-bulk default. |
|
|
108
|
+
| `despatch_on` | `working_time_saved`, `all_time_saved` | Under SHEX, all-time-saved projects the unused allowance across the calendar, Sundays included. |
|
|
109
|
+
| `interruptions_count_on_demurrage` | bool | Default `false`: *once on demurrage, always on demurrage*. Set `true` only when the charter party says interruptions are deducted after demurrage starts. |
|
|
110
|
+
|
|
111
|
+
### `ports[]`
|
|
112
|
+
|
|
113
|
+
| Field | Meaning |
|
|
114
|
+
|---|---|
|
|
115
|
+
| `name`, `purpose` | Free text; `purpose` is echoed, not interpreted. |
|
|
116
|
+
| `nor_tendered`, `nor_accepted` | Port-local naive ISO 8601. `nor_accepted` wins when both are present. |
|
|
117
|
+
| `completed_at` | End of cargo operations. |
|
|
118
|
+
| `allowed` | `{"kind": "fixed_days", "days": "2"}` or `{"kind": "rate", "cargo_qty": "6000", "rate_per_day": "2000"}`. |
|
|
119
|
+
| `interruptions[]` | Explicit `from`/`to`/`reason` records: rain, shifting, breakdown, waiting. There is no "deduct N hours" shortcut; every deduction has a start and an end. |
|
|
120
|
+
|
|
121
|
+
All timestamps are **port local time**, naive. Sunday and holiday exclusions
|
|
122
|
+
are applied on the local calendar day. Convert from UTC before building the
|
|
123
|
+
basis; the engine does not know about time zones on purpose.
|
|
124
|
+
|
|
125
|
+
## The result
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"reversible": false,
|
|
130
|
+
"laytime_basis": "SHEX",
|
|
131
|
+
"allowed_days": "3.00000",
|
|
132
|
+
"used_days": "3.00000",
|
|
133
|
+
"demurrage_days": "1.00000",
|
|
134
|
+
"despatch_days": "0.00000",
|
|
135
|
+
"demurrage_amount": "8000.00",
|
|
136
|
+
"despatch_amount": "0.00",
|
|
137
|
+
"net_amount": "8000.00",
|
|
138
|
+
"currency": "USD",
|
|
139
|
+
"ports": [
|
|
140
|
+
{
|
|
141
|
+
"name": "Samsun",
|
|
142
|
+
"laytime_start": "2026-03-01T18:00",
|
|
143
|
+
"completed_at": "2026-03-06T06:00",
|
|
144
|
+
"used_days": "3.00000",
|
|
145
|
+
"demurrage_start": "2026-03-05T06:00",
|
|
146
|
+
"demurrage_days": "1.00000",
|
|
147
|
+
"allowed_days": "3.00000",
|
|
148
|
+
"despatch_days": "0.00000"
|
|
149
|
+
}
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Days are strings with five decimals, money is a string with two decimals,
|
|
155
|
+
both rounded half-up from exact `Decimal` arithmetic on whole seconds. Nothing
|
|
156
|
+
in the result is a float. Under `reversible: true` the per-port rows omit
|
|
157
|
+
`allowed_days` and `despatch_days` because those only exist for the pool.
|
|
158
|
+
|
|
159
|
+
## Rules the engine commits to
|
|
160
|
+
|
|
161
|
+
- **Once on demurrage, always on demurrage.** After the allowance is
|
|
162
|
+
exhausted nothing is deducted, unless `interruptions_count_on_demurrage`
|
|
163
|
+
says the charter party carves out an exception.
|
|
164
|
+
- **Deductions are records, not numbers.** Every interruption has a start and
|
|
165
|
+
an end. Overlapping interruptions and excepted days are merged before
|
|
166
|
+
counting, so a rain stoppage on a Sunday is not deducted twice.
|
|
167
|
+
- **Half despatch by default**, on working time saved by default.
|
|
168
|
+
- **No time zones.** The basis is port-local; the caller converts.
|
|
169
|
+
- **No guessing.** A missing field raises `KeyError` rather than defaulting.
|
|
170
|
+
|
|
171
|
+
## Golden tests
|
|
172
|
+
|
|
173
|
+
`tests/test_golden.py` pins every rule to a hand-verified example. The policy
|
|
174
|
+
is simple: a golden test is never fixed by updating the expected value. If a
|
|
175
|
+
change to the engine makes one fail, the engine is wrong or the rule has
|
|
176
|
+
changed, and the second case needs a new test with its own hand-verified
|
|
177
|
+
number.
|
|
178
|
+
|
|
179
|
+
## Scope
|
|
180
|
+
|
|
181
|
+
GENCON-type voyage charters only. Time charter, hire and off-hire are out of
|
|
182
|
+
scope, as are SOF parsing, PDF statements and charter-party text
|
|
183
|
+
interpretation; those live in the applications that use this package.
|
|
184
|
+
|
|
185
|
+
## License
|
|
186
|
+
|
|
187
|
+
MIT.
|
laytime-0.1.0/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# laytime
|
|
2
|
+
|
|
3
|
+
GENCON-style laytime, demurrage and despatch calculator for dry-bulk voyage
|
|
4
|
+
charters. One pure function, no dependencies, no floats.
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
from laytime import compute_laytime
|
|
8
|
+
|
|
9
|
+
result = compute_laytime(basis)
|
|
10
|
+
result["net_amount"] # "8000.00" (positive: demurrage due; negative: despatch due)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The engine was extracted unchanged from [MarOps](https://zdeck.tech/case-study-marops.html),
|
|
14
|
+
a back-office system for small shipowners, where it has produced every laytime
|
|
15
|
+
statement since the first release. It is published so that the calculation can
|
|
16
|
+
be read, checked and re-run by anyone who receives one of those statements.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install laytime
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Python 3.12 or newer. No runtime dependencies.
|
|
25
|
+
|
|
26
|
+
## Command line
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
laytime basis.json # pretty JSON result
|
|
30
|
+
laytime basis.json --compact # one line
|
|
31
|
+
cat basis.json | laytime - # from stdin
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Exit code 1 and a message naming the missing field when the basis is
|
|
35
|
+
incomplete; the engine never guesses a value.
|
|
36
|
+
|
|
37
|
+
## The basis
|
|
38
|
+
|
|
39
|
+
Everything the calculation needs is in one JSON document. Store it next to the
|
|
40
|
+
result: replaying the same basis gives the same result, which is what you want
|
|
41
|
+
when the claim is disputed months later.
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"cp": {
|
|
46
|
+
"laytime_basis": "SHEX",
|
|
47
|
+
"unless_used": false,
|
|
48
|
+
"reversible": false,
|
|
49
|
+
"nor_to_start_hours": 12,
|
|
50
|
+
"holidays": ["2026-03-03"],
|
|
51
|
+
"demurrage_rate_per_day": "8000",
|
|
52
|
+
"currency": "USD",
|
|
53
|
+
"despatch_fraction": "half",
|
|
54
|
+
"despatch_on": "working_time_saved",
|
|
55
|
+
"interruptions_count_on_demurrage": false
|
|
56
|
+
},
|
|
57
|
+
"ports": [
|
|
58
|
+
{
|
|
59
|
+
"name": "Samsun",
|
|
60
|
+
"purpose": "loading",
|
|
61
|
+
"nor_tendered": "2026-03-01T06:00",
|
|
62
|
+
"nor_accepted": "2026-03-01T06:00",
|
|
63
|
+
"completed_at": "2026-03-06T06:00",
|
|
64
|
+
"allowed": {"kind": "rate", "cargo_qty": "6000", "rate_per_day": "2000"},
|
|
65
|
+
"interruptions": [
|
|
66
|
+
{"from": "2026-03-02T06:00", "to": "2026-03-02T18:00", "reason": "rain"}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### `cp` — charter-party terms
|
|
74
|
+
|
|
75
|
+
| Field | Values | Meaning |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `laytime_basis` | `SHINC`, `SHEX`, `WWD` | `SHINC` counts every hour. `SHEX` and `WWD` skip Sundays and `holidays`. Weather stoppages are explicit interruptions, so `WWD` behaves like `SHEX` inside the engine. |
|
|
78
|
+
| `unless_used` | bool | Excepted days count anyway; idle time on them is deducted through interruptions. |
|
|
79
|
+
| `reversible` | bool | Pool the allowance of all ports into one ledger (time saved at loading carries to discharge) instead of settling each port on its own. |
|
|
80
|
+
| `nor_to_start_hours` | int | Hours between NOR acceptance and the start of laytime. |
|
|
81
|
+
| `holidays` | list of `YYYY-MM-DD` | Local-calendar holidays treated like Sundays. |
|
|
82
|
+
| `demurrage_rate_per_day` | decimal string | Per day, pro rata. |
|
|
83
|
+
| `currency` | string | Echoed into the result. |
|
|
84
|
+
| `despatch_fraction` | `half`, `full` | Half despatch is the dry-bulk default. |
|
|
85
|
+
| `despatch_on` | `working_time_saved`, `all_time_saved` | Under SHEX, all-time-saved projects the unused allowance across the calendar, Sundays included. |
|
|
86
|
+
| `interruptions_count_on_demurrage` | bool | Default `false`: *once on demurrage, always on demurrage*. Set `true` only when the charter party says interruptions are deducted after demurrage starts. |
|
|
87
|
+
|
|
88
|
+
### `ports[]`
|
|
89
|
+
|
|
90
|
+
| Field | Meaning |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `name`, `purpose` | Free text; `purpose` is echoed, not interpreted. |
|
|
93
|
+
| `nor_tendered`, `nor_accepted` | Port-local naive ISO 8601. `nor_accepted` wins when both are present. |
|
|
94
|
+
| `completed_at` | End of cargo operations. |
|
|
95
|
+
| `allowed` | `{"kind": "fixed_days", "days": "2"}` or `{"kind": "rate", "cargo_qty": "6000", "rate_per_day": "2000"}`. |
|
|
96
|
+
| `interruptions[]` | Explicit `from`/`to`/`reason` records: rain, shifting, breakdown, waiting. There is no "deduct N hours" shortcut; every deduction has a start and an end. |
|
|
97
|
+
|
|
98
|
+
All timestamps are **port local time**, naive. Sunday and holiday exclusions
|
|
99
|
+
are applied on the local calendar day. Convert from UTC before building the
|
|
100
|
+
basis; the engine does not know about time zones on purpose.
|
|
101
|
+
|
|
102
|
+
## The result
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"reversible": false,
|
|
107
|
+
"laytime_basis": "SHEX",
|
|
108
|
+
"allowed_days": "3.00000",
|
|
109
|
+
"used_days": "3.00000",
|
|
110
|
+
"demurrage_days": "1.00000",
|
|
111
|
+
"despatch_days": "0.00000",
|
|
112
|
+
"demurrage_amount": "8000.00",
|
|
113
|
+
"despatch_amount": "0.00",
|
|
114
|
+
"net_amount": "8000.00",
|
|
115
|
+
"currency": "USD",
|
|
116
|
+
"ports": [
|
|
117
|
+
{
|
|
118
|
+
"name": "Samsun",
|
|
119
|
+
"laytime_start": "2026-03-01T18:00",
|
|
120
|
+
"completed_at": "2026-03-06T06:00",
|
|
121
|
+
"used_days": "3.00000",
|
|
122
|
+
"demurrage_start": "2026-03-05T06:00",
|
|
123
|
+
"demurrage_days": "1.00000",
|
|
124
|
+
"allowed_days": "3.00000",
|
|
125
|
+
"despatch_days": "0.00000"
|
|
126
|
+
}
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Days are strings with five decimals, money is a string with two decimals,
|
|
132
|
+
both rounded half-up from exact `Decimal` arithmetic on whole seconds. Nothing
|
|
133
|
+
in the result is a float. Under `reversible: true` the per-port rows omit
|
|
134
|
+
`allowed_days` and `despatch_days` because those only exist for the pool.
|
|
135
|
+
|
|
136
|
+
## Rules the engine commits to
|
|
137
|
+
|
|
138
|
+
- **Once on demurrage, always on demurrage.** After the allowance is
|
|
139
|
+
exhausted nothing is deducted, unless `interruptions_count_on_demurrage`
|
|
140
|
+
says the charter party carves out an exception.
|
|
141
|
+
- **Deductions are records, not numbers.** Every interruption has a start and
|
|
142
|
+
an end. Overlapping interruptions and excepted days are merged before
|
|
143
|
+
counting, so a rain stoppage on a Sunday is not deducted twice.
|
|
144
|
+
- **Half despatch by default**, on working time saved by default.
|
|
145
|
+
- **No time zones.** The basis is port-local; the caller converts.
|
|
146
|
+
- **No guessing.** A missing field raises `KeyError` rather than defaulting.
|
|
147
|
+
|
|
148
|
+
## Golden tests
|
|
149
|
+
|
|
150
|
+
`tests/test_golden.py` pins every rule to a hand-verified example. The policy
|
|
151
|
+
is simple: a golden test is never fixed by updating the expected value. If a
|
|
152
|
+
change to the engine makes one fail, the engine is wrong or the rule has
|
|
153
|
+
changed, and the second case needs a new test with its own hand-verified
|
|
154
|
+
number.
|
|
155
|
+
|
|
156
|
+
## Scope
|
|
157
|
+
|
|
158
|
+
GENCON-type voyage charters only. Time charter, hire and off-hire are out of
|
|
159
|
+
scope, as are SOF parsing, PDF statements and charter-party text
|
|
160
|
+
interpretation; those live in the applications that use this package.
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "laytime"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "GENCON-style laytime, demurrage and despatch calculator for dry-bulk voyage charters"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.12"
|
|
13
|
+
authors = [{ name = "Ali Oktay" }]
|
|
14
|
+
keywords = ["laytime", "demurrage", "despatch", "gencon", "shipping", "charter party", "maritime"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Intended Audience :: Other Audience",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Office/Business",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = []
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/oktaybobus/laytime"
|
|
30
|
+
Issues = "https://github.com/oktaybobus/laytime/issues"
|
|
31
|
+
"Case study" = "https://zdeck.tech/case-study-marops.html"
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
laytime = "laytime.cli:main"
|
|
35
|
+
|
|
36
|
+
[dependency-groups]
|
|
37
|
+
dev = ["pytest>=8", "ruff>=0.6"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/laytime"]
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
line-length = 100
|
|
44
|
+
target-version = "py312"
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
select = ["E", "W", "F", "I", "UP", "B", "T20"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""GENCON-style laytime, demurrage and despatch calculator.
|
|
2
|
+
|
|
3
|
+
The public surface is one pure function, ``compute_laytime``. It takes a
|
|
4
|
+
``basis`` mapping (charter-party terms plus one entry per port) and returns a
|
|
5
|
+
``result`` mapping. Nothing is read from or written to any external system, so
|
|
6
|
+
the same basis produces the same result whenever it is replayed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .engine import compute_laytime
|
|
10
|
+
|
|
11
|
+
__all__ = ["compute_laytime", "__version__"]
|
|
12
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""``laytime`` command line: JSON basis in, JSON result out."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .engine import compute_laytime
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _read(path: str) -> dict:
|
|
15
|
+
if path == "-":
|
|
16
|
+
return json.load(sys.stdin)
|
|
17
|
+
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main(argv: list[str] | None = None) -> int:
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
prog="laytime",
|
|
23
|
+
description="Compute laytime, demurrage and despatch from a JSON basis file.",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument("basis", help="path to a basis JSON file, or '-' for stdin")
|
|
26
|
+
parser.add_argument("--compact", action="store_true", help="single-line JSON output")
|
|
27
|
+
parser.add_argument("--version", action="version", version=f"laytime {__version__}")
|
|
28
|
+
args = parser.parse_args(argv)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
basis = _read(args.basis)
|
|
32
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
33
|
+
parser.exit(2, f"laytime: cannot read basis: {exc}\n")
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
result = compute_laytime(basis)
|
|
37
|
+
except KeyError as exc:
|
|
38
|
+
parser.exit(1, f"laytime: missing field {exc} in basis\n")
|
|
39
|
+
|
|
40
|
+
indent = None if args.compact else 2
|
|
41
|
+
sys.stdout.write(json.dumps(result, indent=indent, ensure_ascii=False) + "\n")
|
|
42
|
+
return 0
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""GENCON laytime engine.
|
|
2
|
+
|
|
3
|
+
Pure function: input is a ``basis`` dict, output is a ``result`` dict. No I/O,
|
|
4
|
+
no clocks, no randomness. The same basis gives the same result six months
|
|
5
|
+
later; in a dispute the stored basis is the evidence.
|
|
6
|
+
|
|
7
|
+
Time convention: every timestamp in the basis is PORT LOCAL time as a naive
|
|
8
|
+
ISO 8601 string (``2026-03-01T06:00``). Sunday and holiday exclusions are
|
|
9
|
+
applied on the local calendar day.
|
|
10
|
+
|
|
11
|
+
Counting regimes (``cp.laytime_basis``):
|
|
12
|
+
|
|
13
|
+
- ``SHINC``: every hour counts.
|
|
14
|
+
- ``SHEX`` / ``WWD``: Sundays and the days listed in ``cp.holidays`` do not
|
|
15
|
+
count. Weather and other stoppages are explicit interruption records, so
|
|
16
|
+
``WWD`` behaves like ``SHEX`` inside the engine.
|
|
17
|
+
- ``cp.unless_used``: excepted days do count (the ship is assumed to have
|
|
18
|
+
worked); idle periods are then deducted through interruption records.
|
|
19
|
+
|
|
20
|
+
Demurrage: once the allowed time is exhausted every hour counts from that
|
|
21
|
+
moment on (*once on demurrage, always on demurrage*). If the charter party
|
|
22
|
+
says otherwise, ``cp.interruptions_count_on_demurrage`` keeps deducting
|
|
23
|
+
interruption records after demurrage has started.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from collections.abc import Iterable
|
|
29
|
+
from datetime import date, datetime, time, timedelta
|
|
30
|
+
from decimal import ROUND_HALF_UP, Decimal
|
|
31
|
+
|
|
32
|
+
DAY_SECONDS = Decimal(86400)
|
|
33
|
+
EXCLUDING_BASES = frozenset({"SHEX", "WWD"})
|
|
34
|
+
|
|
35
|
+
Interval = tuple[datetime, datetime]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _dt(value: str) -> datetime:
|
|
39
|
+
return datetime.fromisoformat(value)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _iso(value: datetime) -> str:
|
|
43
|
+
return value.isoformat(timespec="minutes")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _secs(delta: timedelta) -> int:
|
|
47
|
+
return int(delta.total_seconds())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _days(seconds: int) -> str:
|
|
51
|
+
return str((Decimal(seconds) / DAY_SECONDS).quantize(Decimal("0.00001"), ROUND_HALF_UP))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _money(seconds: int, rate_per_day: Decimal) -> Decimal:
|
|
55
|
+
amount = Decimal(seconds) * rate_per_day / DAY_SECONDS
|
|
56
|
+
return amount.quantize(Decimal("0.01"), ROUND_HALF_UP)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _merge(intervals: Iterable[Interval]) -> list[Interval]:
|
|
60
|
+
ordered = sorted((s, e) for s, e in intervals if e > s)
|
|
61
|
+
merged: list[Interval] = []
|
|
62
|
+
for start, end in ordered:
|
|
63
|
+
if merged and start <= merged[-1][1]:
|
|
64
|
+
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
|
|
65
|
+
else:
|
|
66
|
+
merged.append((start, end))
|
|
67
|
+
return merged
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _clip(intervals: Iterable[Interval], lo: datetime, hi: datetime) -> list[Interval]:
|
|
71
|
+
clipped: list[Interval] = []
|
|
72
|
+
for start, end in intervals:
|
|
73
|
+
s, e = max(start, lo), min(end, hi)
|
|
74
|
+
if s < e:
|
|
75
|
+
clipped.append((s, e))
|
|
76
|
+
return clipped
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_excepted_day(day: date, holidays: set[str]) -> bool:
|
|
80
|
+
return day.weekday() == 6 or day.isoformat() in holidays
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _excepted_day_intervals(lo: datetime, hi: datetime, holidays: set[str]) -> list[Interval]:
|
|
84
|
+
intervals: list[Interval] = []
|
|
85
|
+
day = lo.date()
|
|
86
|
+
while day <= hi.date():
|
|
87
|
+
if _is_excepted_day(day, holidays):
|
|
88
|
+
start = datetime.combine(day, time.min)
|
|
89
|
+
intervals.append((start, start + timedelta(days=1)))
|
|
90
|
+
day += timedelta(days=1)
|
|
91
|
+
return intervals
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _countable_segments(
|
|
95
|
+
lo: datetime, hi: datetime, exclusions: Iterable[Interval]
|
|
96
|
+
) -> list[Interval]:
|
|
97
|
+
merged = _merge(_clip(exclusions, lo, hi))
|
|
98
|
+
segments: list[Interval] = []
|
|
99
|
+
cursor = lo
|
|
100
|
+
for start, end in merged:
|
|
101
|
+
if start > cursor:
|
|
102
|
+
segments.append((cursor, start))
|
|
103
|
+
cursor = max(cursor, end)
|
|
104
|
+
if cursor < hi:
|
|
105
|
+
segments.append((cursor, hi))
|
|
106
|
+
return segments
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _overlap_seconds(intervals: Iterable[Interval], lo: datetime, hi: datetime) -> int:
|
|
110
|
+
return sum(_secs(e - s) for s, e in _merge(_clip(intervals, lo, hi)))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _allowed_seconds(allowed: dict) -> int:
|
|
114
|
+
if allowed["kind"] == "fixed_days":
|
|
115
|
+
days = Decimal(str(allowed["days"]))
|
|
116
|
+
else:
|
|
117
|
+
days = Decimal(str(allowed["cargo_qty"])) / Decimal(str(allowed["rate_per_day"]))
|
|
118
|
+
return int((days * DAY_SECONDS).quantize(Decimal("1"), ROUND_HALF_UP))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _prepare_port(port: dict, cp: dict) -> dict:
|
|
122
|
+
nor = port.get("nor_accepted") or port["nor_tendered"]
|
|
123
|
+
start = _dt(nor) + timedelta(hours=cp.get("nor_to_start_hours", 0))
|
|
124
|
+
end = _dt(port["completed_at"])
|
|
125
|
+
start = min(start, end)
|
|
126
|
+
|
|
127
|
+
interruptions = [(_dt(item["from"]), _dt(item["to"])) for item in port.get("interruptions", [])]
|
|
128
|
+
exclusions = list(interruptions)
|
|
129
|
+
holidays = set(cp.get("holidays", []))
|
|
130
|
+
if cp["laytime_basis"] in EXCLUDING_BASES and not cp.get("unless_used", False):
|
|
131
|
+
exclusions += _excepted_day_intervals(start, end, holidays)
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"name": port["name"],
|
|
135
|
+
"start": start,
|
|
136
|
+
"end": end,
|
|
137
|
+
"interruptions": interruptions,
|
|
138
|
+
"exclusions": exclusions,
|
|
139
|
+
"allowed_seconds": _allowed_seconds(port["allowed"]),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _run_ledger(windows: list[dict], allowed_seconds: int, cp: dict):
|
|
144
|
+
counted = 0
|
|
145
|
+
demurrage_seconds = 0
|
|
146
|
+
demurrage_start: datetime | None = None
|
|
147
|
+
per_window: list[dict] = []
|
|
148
|
+
|
|
149
|
+
for window in windows:
|
|
150
|
+
w_counted = 0
|
|
151
|
+
w_demurrage = 0
|
|
152
|
+
w_demurrage_start: datetime | None = None
|
|
153
|
+
|
|
154
|
+
if demurrage_start is None:
|
|
155
|
+
for seg_start, seg_end in _countable_segments(
|
|
156
|
+
window["start"], window["end"], window["exclusions"]
|
|
157
|
+
):
|
|
158
|
+
seg = _secs(seg_end - seg_start)
|
|
159
|
+
if counted + seg >= allowed_seconds:
|
|
160
|
+
offset = allowed_seconds - counted
|
|
161
|
+
moment = seg_start + timedelta(seconds=offset)
|
|
162
|
+
w_counted += offset
|
|
163
|
+
counted = allowed_seconds
|
|
164
|
+
demurrage_start = moment
|
|
165
|
+
w_demurrage_start = moment
|
|
166
|
+
w_demurrage = _secs(window["end"] - moment)
|
|
167
|
+
if cp.get("interruptions_count_on_demurrage", False):
|
|
168
|
+
w_demurrage -= _overlap_seconds(
|
|
169
|
+
window["interruptions"], moment, window["end"]
|
|
170
|
+
)
|
|
171
|
+
demurrage_seconds += w_demurrage
|
|
172
|
+
break
|
|
173
|
+
counted += seg
|
|
174
|
+
w_counted += seg
|
|
175
|
+
else:
|
|
176
|
+
w_demurrage = _secs(window["end"] - window["start"])
|
|
177
|
+
if cp.get("interruptions_count_on_demurrage", False):
|
|
178
|
+
w_demurrage -= _overlap_seconds(
|
|
179
|
+
window["interruptions"], window["start"], window["end"]
|
|
180
|
+
)
|
|
181
|
+
demurrage_seconds += w_demurrage
|
|
182
|
+
|
|
183
|
+
per_window.append(
|
|
184
|
+
{
|
|
185
|
+
"name": window["name"],
|
|
186
|
+
"laytime_start": _iso(window["start"]),
|
|
187
|
+
"completed_at": _iso(window["end"]),
|
|
188
|
+
"used_days": _days(w_counted),
|
|
189
|
+
"demurrage_start": _iso(w_demurrage_start) if w_demurrage_start else None,
|
|
190
|
+
"demurrage_days": _days(w_demurrage),
|
|
191
|
+
}
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return counted, demurrage_seconds, demurrage_start, per_window
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _project_expiry(start: datetime, remaining_seconds: int, cp: dict) -> datetime:
|
|
198
|
+
"""Where the unused allowance would have expired on the calendar.
|
|
199
|
+
|
|
200
|
+
Needed for ``despatch_on = all_time_saved`` under SHEX: the saved time
|
|
201
|
+
includes the Sundays and holidays the ship would otherwise have sat through.
|
|
202
|
+
"""
|
|
203
|
+
excluding = cp["laytime_basis"] in EXCLUDING_BASES and not cp.get("unless_used", False)
|
|
204
|
+
holidays = set(cp.get("holidays", []))
|
|
205
|
+
cursor = start
|
|
206
|
+
remaining = remaining_seconds
|
|
207
|
+
while remaining > 0:
|
|
208
|
+
if excluding and _is_excepted_day(cursor.date(), holidays):
|
|
209
|
+
cursor = datetime.combine(cursor.date() + timedelta(days=1), time.min)
|
|
210
|
+
continue
|
|
211
|
+
next_midnight = datetime.combine(cursor.date() + timedelta(days=1), time.min)
|
|
212
|
+
chunk = min(remaining, _secs(next_midnight - cursor))
|
|
213
|
+
cursor += timedelta(seconds=chunk)
|
|
214
|
+
remaining -= chunk
|
|
215
|
+
return cursor
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _settle(counted, allowed, demurrage_seconds, demurrage_start, last_end, cp, rate):
|
|
219
|
+
despatch_seconds = 0
|
|
220
|
+
if demurrage_start is None and counted < allowed:
|
|
221
|
+
remaining = allowed - counted
|
|
222
|
+
if cp.get("despatch_on", "working_time_saved") == "all_time_saved":
|
|
223
|
+
despatch_seconds = _secs(_project_expiry(last_end, remaining, cp) - last_end)
|
|
224
|
+
else:
|
|
225
|
+
despatch_seconds = remaining
|
|
226
|
+
|
|
227
|
+
fraction = Decimal("0.5") if cp.get("despatch_fraction", "half") == "half" else Decimal("1")
|
|
228
|
+
demurrage_amount = _money(demurrage_seconds, rate)
|
|
229
|
+
despatch_amount = _money(despatch_seconds, rate * fraction)
|
|
230
|
+
return despatch_seconds, demurrage_amount, despatch_amount
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def compute_laytime(basis: dict) -> dict:
|
|
234
|
+
"""Compute laytime used, demurrage and despatch for one voyage charter.
|
|
235
|
+
|
|
236
|
+
See the module docstring and README for the ``basis`` schema. Raises
|
|
237
|
+
``KeyError`` when a required field is missing; the engine never guesses.
|
|
238
|
+
"""
|
|
239
|
+
cp = basis["cp"]
|
|
240
|
+
rate = Decimal(str(cp["demurrage_rate_per_day"]))
|
|
241
|
+
windows = [_prepare_port(port, cp) for port in basis["ports"]]
|
|
242
|
+
|
|
243
|
+
ports_out: list[dict] = []
|
|
244
|
+
total_allowed = 0
|
|
245
|
+
total_counted = 0
|
|
246
|
+
total_demurrage = 0
|
|
247
|
+
total_despatch = 0
|
|
248
|
+
demurrage_amount = Decimal("0")
|
|
249
|
+
despatch_amount = Decimal("0")
|
|
250
|
+
|
|
251
|
+
if cp.get("reversible", False):
|
|
252
|
+
total_allowed = sum(w["allowed_seconds"] for w in windows)
|
|
253
|
+
counted, dem_secs, dem_start, ports_out = _run_ledger(windows, total_allowed, cp)
|
|
254
|
+
total_counted, total_demurrage = counted, dem_secs
|
|
255
|
+
despatch_secs, demurrage_amount, despatch_amount = _settle(
|
|
256
|
+
counted, total_allowed, dem_secs, dem_start, windows[-1]["end"], cp, rate
|
|
257
|
+
)
|
|
258
|
+
total_despatch = despatch_secs
|
|
259
|
+
else:
|
|
260
|
+
for window in windows:
|
|
261
|
+
allowed = window["allowed_seconds"]
|
|
262
|
+
counted, dem_secs, dem_start, port_rows = _run_ledger([window], allowed, cp)
|
|
263
|
+
despatch_secs, dem_amount, desp_amount = _settle(
|
|
264
|
+
counted, allowed, dem_secs, dem_start, window["end"], cp, rate
|
|
265
|
+
)
|
|
266
|
+
row = port_rows[0]
|
|
267
|
+
row["allowed_days"] = _days(allowed)
|
|
268
|
+
row["despatch_days"] = _days(despatch_secs)
|
|
269
|
+
ports_out.append(row)
|
|
270
|
+
total_allowed += allowed
|
|
271
|
+
total_counted += counted
|
|
272
|
+
total_demurrage += dem_secs
|
|
273
|
+
total_despatch += despatch_secs
|
|
274
|
+
demurrage_amount += dem_amount
|
|
275
|
+
despatch_amount += desp_amount
|
|
276
|
+
|
|
277
|
+
net = demurrage_amount - despatch_amount
|
|
278
|
+
return {
|
|
279
|
+
"reversible": bool(cp.get("reversible", False)),
|
|
280
|
+
"laytime_basis": cp["laytime_basis"],
|
|
281
|
+
"allowed_days": _days(total_allowed),
|
|
282
|
+
"used_days": _days(total_counted),
|
|
283
|
+
"demurrage_days": _days(total_demurrage),
|
|
284
|
+
"despatch_days": _days(total_despatch),
|
|
285
|
+
"demurrage_amount": str(demurrage_amount),
|
|
286
|
+
"despatch_amount": str(despatch_amount),
|
|
287
|
+
"net_amount": str(net),
|
|
288
|
+
"currency": cp.get("currency", "USD"),
|
|
289
|
+
"ports": ports_out,
|
|
290
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from laytime.cli import main
|
|
6
|
+
|
|
7
|
+
BASIS = {
|
|
8
|
+
"cp": {
|
|
9
|
+
"laytime_basis": "SHEX",
|
|
10
|
+
"demurrage_rate_per_day": "6000",
|
|
11
|
+
"currency": "USD",
|
|
12
|
+
"despatch_fraction": "half",
|
|
13
|
+
"despatch_on": "working_time_saved",
|
|
14
|
+
"nor_to_start_hours": 0,
|
|
15
|
+
"holidays": [],
|
|
16
|
+
},
|
|
17
|
+
"ports": [
|
|
18
|
+
{
|
|
19
|
+
"name": "Samsun",
|
|
20
|
+
"purpose": "loading",
|
|
21
|
+
"nor_tendered": "2026-03-02T08:00",
|
|
22
|
+
"nor_accepted": "2026-03-02T08:00",
|
|
23
|
+
"completed_at": "2026-03-04T08:00",
|
|
24
|
+
"allowed": {"kind": "fixed_days", "days": "6"},
|
|
25
|
+
"interruptions": [],
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_cli_reads_file_and_prints_result(tmp_path, capsys):
|
|
32
|
+
path = tmp_path / "basis.json"
|
|
33
|
+
path.write_text(json.dumps(BASIS), encoding="utf-8")
|
|
34
|
+
|
|
35
|
+
assert main([str(path), "--compact"]) == 0
|
|
36
|
+
|
|
37
|
+
out = json.loads(capsys.readouterr().out)
|
|
38
|
+
assert out["despatch_amount"] == "12000.00"
|
|
39
|
+
assert out["net_amount"] == "-12000.00"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_cli_reads_stdin(monkeypatch, capsys):
|
|
43
|
+
import io
|
|
44
|
+
|
|
45
|
+
monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(BASIS)))
|
|
46
|
+
assert main(["-"]) == 0
|
|
47
|
+
assert '"despatch_days": "4.00000"' in capsys.readouterr().out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_cli_reports_missing_field(tmp_path, capsys):
|
|
51
|
+
broken = {"cp": {"laytime_basis": "SHINC"}, "ports": []}
|
|
52
|
+
path = tmp_path / "broken.json"
|
|
53
|
+
path.write_text(json.dumps(broken), encoding="utf-8")
|
|
54
|
+
|
|
55
|
+
with pytest.raises(SystemExit) as exc:
|
|
56
|
+
main([str(path)])
|
|
57
|
+
assert exc.value.code == 1
|
|
58
|
+
assert "demurrage_rate_per_day" in capsys.readouterr().err
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_cli_reports_unreadable_file(tmp_path, capsys):
|
|
62
|
+
with pytest.raises(SystemExit) as exc:
|
|
63
|
+
main([str(tmp_path / "missing.json")])
|
|
64
|
+
assert exc.value.code == 2
|
|
65
|
+
assert "cannot read basis" in capsys.readouterr().err
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Golden laytime tests.
|
|
2
|
+
|
|
3
|
+
Every expected value was verified by hand against the charter-party rule it
|
|
4
|
+
exercises. If the engine changes and one of these fails, the engine is wrong;
|
|
5
|
+
these tests are never "fixed" by updating the expected value.
|
|
6
|
+
|
|
7
|
+
Calendar anchor: 2026-03-01 is a Sunday; 02 Mon, 08 Sun, 09 Mon.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from laytime import compute_laytime
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def basis(cp_extra=None, ports=None):
|
|
16
|
+
cp = {
|
|
17
|
+
"laytime_basis": "SHINC",
|
|
18
|
+
"unless_used": False,
|
|
19
|
+
"reversible": False,
|
|
20
|
+
"demurrage_rate_per_day": "8000",
|
|
21
|
+
"currency": "USD",
|
|
22
|
+
"despatch_fraction": "half",
|
|
23
|
+
"despatch_on": "working_time_saved",
|
|
24
|
+
"nor_to_start_hours": 0,
|
|
25
|
+
"holidays": [],
|
|
26
|
+
}
|
|
27
|
+
cp.update(cp_extra or {})
|
|
28
|
+
return {"cp": cp, "ports": ports or []}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def port(name="Samsun", nor="2026-03-02T08:00", completed="2026-03-04T08:00", **kwargs):
|
|
32
|
+
data = {
|
|
33
|
+
"name": name,
|
|
34
|
+
"purpose": "loading",
|
|
35
|
+
"nor_tendered": nor,
|
|
36
|
+
"nor_accepted": nor,
|
|
37
|
+
"completed_at": completed,
|
|
38
|
+
"allowed": {"kind": "fixed_days", "days": "2"},
|
|
39
|
+
"interruptions": [],
|
|
40
|
+
}
|
|
41
|
+
data.update(kwargs)
|
|
42
|
+
return data
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_golden_demurrage_shinc_with_interruption():
|
|
46
|
+
"""6000 mt at 2000 mt/day = 3 days allowed; a 12 h rain stoppage shifts the count."""
|
|
47
|
+
result = compute_laytime(
|
|
48
|
+
basis(
|
|
49
|
+
{"nor_to_start_hours": 12},
|
|
50
|
+
[
|
|
51
|
+
port(
|
|
52
|
+
nor="2026-03-01T06:00",
|
|
53
|
+
completed="2026-03-06T06:00",
|
|
54
|
+
allowed={"kind": "rate", "cargo_qty": "6000", "rate_per_day": "2000"},
|
|
55
|
+
interruptions=[
|
|
56
|
+
{"from": "2026-03-02T06:00", "to": "2026-03-02T18:00", "reason": "rain"}
|
|
57
|
+
],
|
|
58
|
+
)
|
|
59
|
+
],
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
assert result["allowed_days"] == "3.00000"
|
|
63
|
+
assert result["used_days"] == "3.00000"
|
|
64
|
+
assert result["ports"][0]["laytime_start"] == "2026-03-01T18:00"
|
|
65
|
+
assert result["ports"][0]["demurrage_start"] == "2026-03-05T06:00"
|
|
66
|
+
assert result["demurrage_days"] == "1.00000"
|
|
67
|
+
assert result["demurrage_amount"] == "8000.00"
|
|
68
|
+
assert result["despatch_amount"] == "0.00"
|
|
69
|
+
assert result["net_amount"] == "8000.00"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_golden_despatch_shex_half():
|
|
73
|
+
"""6 days allowed, done in 2; WTS saves 4 days, ATS skips the Sunday and saves 5."""
|
|
74
|
+
common_port = port(completed="2026-03-04T08:00", allowed={"kind": "fixed_days", "days": "6"})
|
|
75
|
+
|
|
76
|
+
wts = compute_laytime(
|
|
77
|
+
basis({"laytime_basis": "SHEX", "demurrage_rate_per_day": "6000"}, [common_port])
|
|
78
|
+
)
|
|
79
|
+
assert wts["used_days"] == "2.00000"
|
|
80
|
+
assert wts["despatch_days"] == "4.00000"
|
|
81
|
+
assert wts["despatch_amount"] == "12000.00"
|
|
82
|
+
assert wts["demurrage_amount"] == "0.00"
|
|
83
|
+
assert wts["net_amount"] == "-12000.00"
|
|
84
|
+
|
|
85
|
+
ats = compute_laytime(
|
|
86
|
+
basis(
|
|
87
|
+
{
|
|
88
|
+
"laytime_basis": "SHEX",
|
|
89
|
+
"demurrage_rate_per_day": "6000",
|
|
90
|
+
"despatch_on": "all_time_saved",
|
|
91
|
+
},
|
|
92
|
+
[common_port],
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
assert ats["despatch_days"] == "5.00000"
|
|
96
|
+
assert ats["despatch_amount"] == "15000.00"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_golden_once_on_demurrage():
|
|
100
|
+
"""After demurrage starts neither rain nor Sunday is deducted."""
|
|
101
|
+
rain_after = {"from": "2026-03-04T10:00", "to": "2026-03-04T16:00", "reason": "rain"}
|
|
102
|
+
result = compute_laytime(
|
|
103
|
+
basis(
|
|
104
|
+
{"laytime_basis": "SHEX"},
|
|
105
|
+
[
|
|
106
|
+
port(
|
|
107
|
+
completed="2026-03-09T08:00",
|
|
108
|
+
allowed={"kind": "fixed_days", "days": "1"},
|
|
109
|
+
interruptions=[rain_after],
|
|
110
|
+
)
|
|
111
|
+
],
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
assert result["ports"][0]["demurrage_start"] == "2026-03-03T08:00"
|
|
115
|
+
assert result["demurrage_days"] == "6.00000"
|
|
116
|
+
assert result["demurrage_amount"] == "48000.00"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_same_rain_before_demurrage_does_reduce():
|
|
120
|
+
result = compute_laytime(
|
|
121
|
+
basis(
|
|
122
|
+
{"laytime_basis": "SHEX"},
|
|
123
|
+
[
|
|
124
|
+
port(
|
|
125
|
+
completed="2026-03-09T08:00",
|
|
126
|
+
allowed={"kind": "fixed_days", "days": "1"},
|
|
127
|
+
interruptions=[
|
|
128
|
+
{"from": "2026-03-02T10:00", "to": "2026-03-02T16:00", "reason": "rain"}
|
|
129
|
+
],
|
|
130
|
+
)
|
|
131
|
+
],
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
assert result["ports"][0]["demurrage_start"] == "2026-03-03T14:00"
|
|
135
|
+
assert result["demurrage_days"] == "5.75000"
|
|
136
|
+
assert result["demurrage_amount"] == "46000.00"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_cp_exception_lets_interruptions_count_on_demurrage():
|
|
140
|
+
result = compute_laytime(
|
|
141
|
+
basis(
|
|
142
|
+
{"laytime_basis": "SHEX", "interruptions_count_on_demurrage": True},
|
|
143
|
+
[
|
|
144
|
+
port(
|
|
145
|
+
completed="2026-03-09T08:00",
|
|
146
|
+
allowed={"kind": "fixed_days", "days": "1"},
|
|
147
|
+
interruptions=[
|
|
148
|
+
{"from": "2026-03-04T10:00", "to": "2026-03-04T16:00", "reason": "rain"}
|
|
149
|
+
],
|
|
150
|
+
)
|
|
151
|
+
],
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
assert result["demurrage_days"] == "5.75000"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_shex_excludes_sunday_before_demurrage():
|
|
158
|
+
result = compute_laytime(
|
|
159
|
+
basis(
|
|
160
|
+
{"laytime_basis": "SHEX"},
|
|
161
|
+
[port(nor="2026-03-06T08:00", completed="2026-03-10T08:00")],
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
assert result["ports"][0]["demurrage_start"] == "2026-03-09T08:00"
|
|
165
|
+
assert result["used_days"] == "2.00000"
|
|
166
|
+
assert result["demurrage_days"] == "1.00000"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_unless_used_counts_sunday():
|
|
170
|
+
result = compute_laytime(
|
|
171
|
+
basis(
|
|
172
|
+
{"laytime_basis": "SHEX", "unless_used": True},
|
|
173
|
+
[port(nor="2026-03-06T08:00", completed="2026-03-09T08:00")],
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
assert result["ports"][0]["demurrage_start"] == "2026-03-08T08:00"
|
|
177
|
+
assert result["demurrage_days"] == "1.00000"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_holiday_is_excepted_like_sunday():
|
|
181
|
+
"""A listed holiday (Tuesday 2026-03-03) is skipped under SHEX."""
|
|
182
|
+
result = compute_laytime(
|
|
183
|
+
basis(
|
|
184
|
+
{"laytime_basis": "SHEX", "holidays": ["2026-03-03"]},
|
|
185
|
+
[port(nor="2026-03-02T08:00", completed="2026-03-05T08:00")],
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
assert result["ports"][0]["demurrage_start"] == "2026-03-05T08:00"
|
|
189
|
+
assert result["used_days"] == "2.00000"
|
|
190
|
+
assert result["demurrage_days"] == "0.00000"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_reversible_combines_ports():
|
|
194
|
+
"""Time saved at the load port carries over to discharge under reversible laytime."""
|
|
195
|
+
ports = [
|
|
196
|
+
port(name="Samsun", nor="2026-03-02T08:00", completed="2026-03-03T08:00"),
|
|
197
|
+
port(
|
|
198
|
+
name="İskenderun",
|
|
199
|
+
purpose="discharging",
|
|
200
|
+
nor="2026-03-05T08:00",
|
|
201
|
+
nor_accepted="2026-03-05T08:00",
|
|
202
|
+
nor_tendered="2026-03-05T08:00",
|
|
203
|
+
completed="2026-03-08T20:00",
|
|
204
|
+
),
|
|
205
|
+
]
|
|
206
|
+
result = compute_laytime(basis({"reversible": True}, ports))
|
|
207
|
+
assert result["allowed_days"] == "4.00000"
|
|
208
|
+
assert result["ports"][1]["demurrage_start"] == "2026-03-08T08:00"
|
|
209
|
+
assert result["demurrage_days"] == "0.50000"
|
|
210
|
+
assert result["demurrage_amount"] == "4000.00"
|
|
211
|
+
|
|
212
|
+
split = compute_laytime(basis({"reversible": False}, ports))
|
|
213
|
+
assert split["demurrage_days"] == "1.50000"
|
|
214
|
+
assert split["despatch_days"] == "1.00000"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_result_is_reproducible():
|
|
218
|
+
payload = basis(
|
|
219
|
+
{"laytime_basis": "SHEX", "nor_to_start_hours": 12},
|
|
220
|
+
[
|
|
221
|
+
port(
|
|
222
|
+
nor="2026-03-01T06:00",
|
|
223
|
+
completed="2026-03-06T06:00",
|
|
224
|
+
allowed={"kind": "rate", "cargo_qty": "6000", "rate_per_day": "2000"},
|
|
225
|
+
interruptions=[
|
|
226
|
+
{"from": "2026-03-02T06:00", "to": "2026-03-02T18:00", "reason": "rain"}
|
|
227
|
+
],
|
|
228
|
+
)
|
|
229
|
+
],
|
|
230
|
+
)
|
|
231
|
+
assert compute_laytime(payload) == compute_laytime(payload)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_result_is_json_serialisable_without_floats():
|
|
235
|
+
import json
|
|
236
|
+
|
|
237
|
+
result = compute_laytime(basis(None, [port()]))
|
|
238
|
+
text = json.dumps(result)
|
|
239
|
+
assert '"demurrage_amount": "0.00"' in text
|
|
240
|
+
assert "float" not in {type(v).__name__ for v in result.values()}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_invalid_allowed_kind_raises():
|
|
244
|
+
with pytest.raises(KeyError):
|
|
245
|
+
compute_laytime(basis(None, [port(allowed={"kind": "rate"})]))
|
laytime-0.1.0/uv.lock
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 3
|
|
3
|
+
requires-python = ">=3.12"
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "colorama"
|
|
7
|
+
version = "0.4.6"
|
|
8
|
+
source = { registry = "https://pypi.org/simple" }
|
|
9
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
10
|
+
wheels = [
|
|
11
|
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[[package]]
|
|
15
|
+
name = "iniconfig"
|
|
16
|
+
version = "2.3.0"
|
|
17
|
+
source = { registry = "https://pypi.org/simple" }
|
|
18
|
+
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
|
19
|
+
wheels = [
|
|
20
|
+
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[[package]]
|
|
24
|
+
name = "laytime"
|
|
25
|
+
version = "0.1.0"
|
|
26
|
+
source = { editable = "." }
|
|
27
|
+
|
|
28
|
+
[package.dev-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
{ name = "pytest" },
|
|
31
|
+
{ name = "ruff" },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[package.metadata]
|
|
35
|
+
|
|
36
|
+
[package.metadata.requires-dev]
|
|
37
|
+
dev = [
|
|
38
|
+
{ name = "pytest", specifier = ">=8" },
|
|
39
|
+
{ name = "ruff", specifier = ">=0.6" },
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[[package]]
|
|
43
|
+
name = "packaging"
|
|
44
|
+
version = "26.3"
|
|
45
|
+
source = { registry = "https://pypi.org/simple" }
|
|
46
|
+
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
|
47
|
+
wheels = [
|
|
48
|
+
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[[package]]
|
|
52
|
+
name = "pluggy"
|
|
53
|
+
version = "1.6.0"
|
|
54
|
+
source = { registry = "https://pypi.org/simple" }
|
|
55
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
|
56
|
+
wheels = [
|
|
57
|
+
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
[[package]]
|
|
61
|
+
name = "pygments"
|
|
62
|
+
version = "2.21.0"
|
|
63
|
+
source = { registry = "https://pypi.org/simple" }
|
|
64
|
+
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
|
65
|
+
wheels = [
|
|
66
|
+
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
[[package]]
|
|
70
|
+
name = "pytest"
|
|
71
|
+
version = "9.1.1"
|
|
72
|
+
source = { registry = "https://pypi.org/simple" }
|
|
73
|
+
dependencies = [
|
|
74
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
75
|
+
{ name = "iniconfig" },
|
|
76
|
+
{ name = "packaging" },
|
|
77
|
+
{ name = "pluggy" },
|
|
78
|
+
{ name = "pygments" },
|
|
79
|
+
]
|
|
80
|
+
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
|
81
|
+
wheels = [
|
|
82
|
+
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
[[package]]
|
|
86
|
+
name = "ruff"
|
|
87
|
+
version = "0.16.5"
|
|
88
|
+
source = { registry = "https://pypi.org/simple" }
|
|
89
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" }
|
|
90
|
+
wheels = [
|
|
91
|
+
{ url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" },
|
|
92
|
+
{ url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" },
|
|
93
|
+
{ url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" },
|
|
94
|
+
{ url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" },
|
|
95
|
+
{ url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" },
|
|
96
|
+
{ url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" },
|
|
97
|
+
{ url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" },
|
|
98
|
+
{ url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" },
|
|
99
|
+
{ url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" },
|
|
100
|
+
{ url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" },
|
|
101
|
+
{ url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" },
|
|
102
|
+
{ url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" },
|
|
103
|
+
{ url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" },
|
|
104
|
+
{ url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" },
|
|
105
|
+
{ url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" },
|
|
106
|
+
{ url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" },
|
|
107
|
+
{ url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" },
|
|
108
|
+
]
|