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.
@@ -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
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .DS_Store
@@ -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.
@@ -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,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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
+ ]