vessel-api-python 1.1.0__tar.gz → 1.3.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.
Files changed (29) hide show
  1. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/PKG-INFO +22 -5
  2. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/README.md +21 -4
  3. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/examples/async_basic.py +19 -1
  4. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/examples/basic.py +20 -1
  5. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/pyproject.toml +1 -1
  6. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/src/vessel_api_python/__init__.py +4 -0
  7. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/src/vessel_api_python/_models.py +28 -0
  8. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/src/vessel_api_python/_services.py +54 -20
  9. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/tests/test_services.py +52 -0
  10. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/tests/test_smoke.py +28 -1
  11. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/.github/workflows/ci.yml +0 -0
  12. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/.github/workflows/publish.yml +0 -0
  13. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/.gitignore +0 -0
  14. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/LICENSE +0 -0
  15. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/Makefile +0 -0
  16. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/SECURITY.md +0 -0
  17. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/openapi/swagger.json +0 -0
  18. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/src/vessel_api_python/_client.py +0 -0
  19. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/src/vessel_api_python/_constants.py +0 -0
  20. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/src/vessel_api_python/_errors.py +0 -0
  21. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/src/vessel_api_python/_iterator.py +0 -0
  22. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/src/vessel_api_python/_transport.py +0 -0
  23. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/src/vessel_api_python/py.typed +0 -0
  24. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/tests/__init__.py +0 -0
  25. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/tests/conftest.py +0 -0
  26. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/tests/test_client.py +0 -0
  27. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/tests/test_errors.py +0 -0
  28. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/tests/test_iterator.py +0 -0
  29. {vessel_api_python-1.1.0 → vessel_api_python-1.3.0}/tests/test_transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vessel-api-python
3
- Version: 1.1.0
3
+ Version: 1.3.0
4
4
  Summary: Python client for the Vessel Tracking API — maritime vessel tracking, port events, emissions, and navigation data.
5
5
  Project-URL: Documentation, https://vesselapi.com/docs
6
6
  Project-URL: Repository, https://github.com/vessel-api/vesselapi-python
@@ -101,6 +101,23 @@ asyncio.run(main())
101
101
 
102
102
  **37 methods total.**
103
103
 
104
+ ## Vessel Lookup & Location
105
+
106
+ ```python
107
+ # Get vessel details by IMO number (defaults to IMO; pass filter_id_type="mmsi" for MMSI).
108
+ vessel = client.vessels.get("9811000")
109
+ print(f"{vessel.vessel.name} ({vessel.vessel.vessel_type})")
110
+
111
+ # Get the vessel's latest AIS position.
112
+ pos = client.vessels.position("9811000")
113
+ print(f"Position: {pos.vessel_position.latitude}, {pos.vessel_position.longitude}")
114
+
115
+ # Find all vessels within 10 km of Rotterdam.
116
+ nearby = client.location.vessels_radius(latitude=51.9225, longitude=4.47917, radius=10000)
117
+ for v in nearby.vessels or []:
118
+ print(f"{v.vessel_name} at {v.latitude}, {v.longitude}")
119
+ ```
120
+
104
121
  ## Error Handling
105
122
 
106
123
  All methods raise specific exception types on non-2xx responses:
@@ -126,15 +143,15 @@ Every list endpoint has an `all_*` / `list_all` variant returning an iterator:
126
143
 
127
144
  ```python
128
145
  # Sync
129
- for vessel in client.search.all_vessels(filter_name="tanker"):
146
+ for vessel in client.search.all_vessels(filter_vessel_type="Tanker"):
130
147
  print(vessel.name)
131
148
 
132
149
  # Async
133
- async for vessel in client.search.all_vessels(filter_name="tanker"):
150
+ async for vessel in client.search.all_vessels(filter_vessel_type="Tanker"):
134
151
  print(vessel.name)
135
152
 
136
- # Collect all at once
137
- vessels = client.search.all_vessels(filter_name="tanker").collect()
153
+ # Collect a bounded set at once
154
+ vessels = client.search.all_vessels(filter_vessel_type="Tanker", pagination_limit=50).collect()
138
155
  ```
139
156
 
140
157
  ## Configuration
@@ -67,6 +67,23 @@ asyncio.run(main())
67
67
 
68
68
  **37 methods total.**
69
69
 
70
+ ## Vessel Lookup & Location
71
+
72
+ ```python
73
+ # Get vessel details by IMO number (defaults to IMO; pass filter_id_type="mmsi" for MMSI).
74
+ vessel = client.vessels.get("9811000")
75
+ print(f"{vessel.vessel.name} ({vessel.vessel.vessel_type})")
76
+
77
+ # Get the vessel's latest AIS position.
78
+ pos = client.vessels.position("9811000")
79
+ print(f"Position: {pos.vessel_position.latitude}, {pos.vessel_position.longitude}")
80
+
81
+ # Find all vessels within 10 km of Rotterdam.
82
+ nearby = client.location.vessels_radius(latitude=51.9225, longitude=4.47917, radius=10000)
83
+ for v in nearby.vessels or []:
84
+ print(f"{v.vessel_name} at {v.latitude}, {v.longitude}")
85
+ ```
86
+
70
87
  ## Error Handling
71
88
 
72
89
  All methods raise specific exception types on non-2xx responses:
@@ -92,15 +109,15 @@ Every list endpoint has an `all_*` / `list_all` variant returning an iterator:
92
109
 
93
110
  ```python
94
111
  # Sync
95
- for vessel in client.search.all_vessels(filter_name="tanker"):
112
+ for vessel in client.search.all_vessels(filter_vessel_type="Tanker"):
96
113
  print(vessel.name)
97
114
 
98
115
  # Async
99
- async for vessel in client.search.all_vessels(filter_name="tanker"):
116
+ async for vessel in client.search.all_vessels(filter_vessel_type="Tanker"):
100
117
  print(vessel.name)
101
118
 
102
- # Collect all at once
103
- vessels = client.search.all_vessels(filter_name="tanker").collect()
119
+ # Collect a bounded set at once
120
+ vessels = client.search.all_vessels(filter_vessel_type="Tanker", pagination_limit=50).collect()
104
121
  ```
105
122
 
106
123
  ## Configuration
@@ -4,7 +4,7 @@ import asyncio
4
4
  import os
5
5
  import sys
6
6
 
7
- from vesselapi import AsyncVesselClient, VesselAPIError
7
+ from vessel_api_python import AsyncVesselClient, VesselAPIError
8
8
 
9
9
 
10
10
  async def main() -> None:
@@ -26,6 +26,24 @@ async def main() -> None:
26
26
  if port.port:
27
27
  print(f"Port: {port.port.name} ({port.port.unlo_code})")
28
28
 
29
+ # Get vessel details by IMO number (defaults to IMO; pass filter_id_type="mmsi" for MMSI).
30
+ print("\n--- Vessel by IMO ---")
31
+ vessel = await client.vessels.get("9811000")
32
+ if vessel.vessel:
33
+ print(f"Vessel: {vessel.vessel.name} (Type: {vessel.vessel.vessel_type})")
34
+
35
+ # Get the vessel's latest AIS position.
36
+ print("\n--- Vessel Position ---")
37
+ pos = await client.vessels.position("9811000")
38
+ if pos.vessel_position:
39
+ print(f"Position: {pos.vessel_position.latitude}, {pos.vessel_position.longitude}")
40
+
41
+ # Find vessels within 10 km of Rotterdam.
42
+ print("\n--- Vessels Near Rotterdam ---")
43
+ nearby = await client.location.vessels_radius(latitude=51.9225, longitude=4.47917, radius=10000)
44
+ for v in nearby.vessels or []:
45
+ print(f"{v.vessel_name} (IMO: {v.imo}) at {v.latitude}, {v.longitude}")
46
+
29
47
  # Error handling.
30
48
  print("\n--- Error Handling ---")
31
49
  try:
@@ -3,7 +3,7 @@
3
3
  import os
4
4
  import sys
5
5
 
6
- from vesselapi import VesselClient, VesselAPIError
6
+ from vessel_api_python import VesselClient, VesselAPIError
7
7
 
8
8
 
9
9
  def main() -> None:
@@ -38,6 +38,25 @@ def main() -> None:
38
38
  if port.port:
39
39
  print(f"Port: {port.port.name} ({port.port.unlo_code})")
40
40
 
41
+ # Get vessel details by IMO number (defaults to IMO; pass filter_id_type="mmsi" for MMSI).
42
+ print("\n--- Vessel by IMO ---")
43
+ vessel = client.vessels.get("9811000")
44
+ if vessel.vessel:
45
+ print(f"Vessel: {vessel.vessel.name} (Type: {vessel.vessel.vessel_type})")
46
+
47
+ # Get the vessel's latest AIS position.
48
+ print("\n--- Vessel Position ---")
49
+ pos = client.vessels.position("9811000")
50
+ if pos.vessel_position:
51
+ print(f"Position: {pos.vessel_position.latitude}, {pos.vessel_position.longitude}")
52
+ print(f"Speed: {pos.vessel_position.sog} knots, Heading: {pos.vessel_position.heading}")
53
+
54
+ # Find vessels within 10 km of Rotterdam.
55
+ print("\n--- Vessels Near Rotterdam ---")
56
+ nearby = client.location.vessels_radius(latitude=51.9225, longitude=4.47917, radius=10000)
57
+ for v in nearby.vessels or []:
58
+ print(f"{v.vessel_name} (IMO: {v.imo}) at {v.latitude}, {v.longitude}")
59
+
41
60
  # Handle a not-found port gracefully.
42
61
  print("\n--- Not Found Handling ---")
43
62
  try:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "vessel-api-python"
7
- version = "1.1.0"
7
+ version = "1.3.0"
8
8
  description = "Python client for the Vessel Tracking API — maritime vessel tracking, port events, emissions, and navigation data."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -65,11 +65,13 @@ from ._models import (
65
65
  PortEvent,
66
66
  PortEventResponse,
67
67
  PortEventsResponse,
68
+ PortInboundResponse,
68
69
  PortReference,
69
70
  PortResponse,
70
71
  PortsWithinLocationResponse,
71
72
  RadioBeacon,
72
73
  RadioBeaconsWithinLocationResponse,
74
+ ResolutionMeta,
73
75
  TypesInspectionDetailResponse,
74
76
  TypesInspectionsResponse,
75
77
  TypesOwnershipResponse,
@@ -111,6 +113,7 @@ __all__ = [
111
113
  "VesselReference",
112
114
  "VesselFormerName",
113
115
  "BroadcastStation",
116
+ "ResolutionMeta",
114
117
  # Models — Classification sub-models
115
118
  "ClassificationCertificate",
116
119
  "ClassificationCondition",
@@ -159,6 +162,7 @@ __all__ = [
159
162
  "PortEvent",
160
163
  "PortEventResponse",
161
164
  "PortEventsResponse",
165
+ "PortInboundResponse",
162
166
  # Models — Search
163
167
  "FindVesselsResponse",
164
168
  "FindPortsResponse",
@@ -224,6 +224,18 @@ class ClassificationYard(_Base):
224
224
  keel_date: str | None = Field(default=None, alias="keelDate")
225
225
 
226
226
 
227
+ # ---------------------------------------------------------------------------
228
+ # Resolution metadata
229
+ # ---------------------------------------------------------------------------
230
+
231
+
232
+ class ResolutionMeta(_Base):
233
+ """Metadata about ID resolution fallback. Present when the API resolved using a different ID type."""
234
+ requested_id_type: str | None = Field(default=None, alias="requestedIdType")
235
+ resolved_id_type: str | None = Field(default=None, alias="resolvedIdType")
236
+ resolved_id: int | None = Field(default=None, alias="resolvedId")
237
+
238
+
227
239
  # ---------------------------------------------------------------------------
228
240
  # Vessel models
229
241
  # ---------------------------------------------------------------------------
@@ -264,6 +276,7 @@ class VesselResponse(_Base):
264
276
  """Response for a single vessel lookup."""
265
277
 
266
278
  vessel: Vessel | None = None
279
+ meta: ResolutionMeta | None = Field(default=None, alias="_meta")
267
280
 
268
281
 
269
282
  class VesselPosition(_Base):
@@ -290,6 +303,7 @@ class VesselPositionResponse(_Base):
290
303
  vessel_position: VesselPosition | None = Field(
291
304
  default=None, alias="vesselPosition"
292
305
  )
306
+ meta: ResolutionMeta | None = Field(default=None, alias="_meta")
293
307
 
294
308
 
295
309
  class VesselPositionsResponse(_Base):
@@ -299,6 +313,7 @@ class VesselPositionsResponse(_Base):
299
313
  default=None, alias="vesselPositions"
300
314
  )
301
315
  next_token: str | None = Field(default=None, alias="nextToken")
316
+ meta: ResolutionMeta | None = Field(default=None, alias="_meta")
302
317
 
303
318
 
304
319
  # ---------------------------------------------------------------------------
@@ -352,6 +367,7 @@ class MarineCasualtiesResponse(_Base):
352
367
 
353
368
  casualties: list[MarineCasualty] | None = None
354
369
  next_token: str | None = Field(default=None, alias="nextToken")
370
+ meta: ResolutionMeta | None = Field(default=None, alias="_meta")
355
371
 
356
372
 
357
373
  class ClassificationVessel(_Base):
@@ -379,6 +395,7 @@ class ClassificationResponse(_Base):
379
395
  """Response for vessel classification."""
380
396
 
381
397
  classification: ClassificationVessel | None = None
398
+ meta: ResolutionMeta | None = Field(default=None, alias="_meta")
382
399
 
383
400
 
384
401
  class VesselEmission(_Base):
@@ -432,12 +449,14 @@ class VesselEmissionsResponse(_Base):
432
449
 
433
450
  emissions: list[VesselEmission] | None = None
434
451
  next_token: str | None = Field(default=None, alias="nextToken")
452
+ meta: ResolutionMeta | None = Field(default=None, alias="_meta")
435
453
 
436
454
 
437
455
  class VesselETA(_Base):
438
456
  """Vessel Estimated Time of Arrival information."""
439
457
 
440
458
  destination: str | None = None
459
+ destination_port: str | None = None
441
460
  draught: float | None = None
442
461
  eta: str | None = None
443
462
  imo: int | None = None
@@ -450,6 +469,13 @@ class VesselETAResponse(_Base):
450
469
  """Response for vessel ETA."""
451
470
 
452
471
  vessel_eta: VesselETA | None = Field(default=None, alias="vesselEta")
472
+ meta: ResolutionMeta | None = Field(default=None, alias="_meta")
473
+
474
+
475
+ class PortInboundResponse(_Base):
476
+ """Response containing vessels heading to a port."""
477
+ vessel_etas: list[VesselETA] | None = Field(default=None, alias="vesselETAs")
478
+ next_token: str | None = Field(default=None, alias="nextToken")
453
479
 
454
480
 
455
481
  # ---------------------------------------------------------------------------
@@ -652,12 +678,14 @@ class PortEventsResponse(_Base):
652
678
  default=None, alias="portEvents"
653
679
  )
654
680
  next_token: str | None = Field(default=None, alias="nextToken")
681
+ meta: ResolutionMeta | None = Field(default=None, alias="_meta")
655
682
 
656
683
 
657
684
  class PortEventResponse(_Base):
658
685
  """Response for a single port event (e.g. last by vessel)."""
659
686
 
660
687
  port_event: PortEvent | None = Field(default=None, alias="portEvent")
688
+ meta: ResolutionMeta | None = Field(default=None, alias="_meta")
661
689
 
662
690
 
663
691
  # ---------------------------------------------------------------------------
@@ -36,6 +36,7 @@ from ._models import (
36
36
  PortEvent,
37
37
  PortEventResponse,
38
38
  PortEventsResponse,
39
+ PortInboundResponse,
39
40
  PortResponse,
40
41
  PortsWithinLocationResponse,
41
42
  RadioBeacon,
@@ -46,6 +47,7 @@ from ._models import (
46
47
  Vessel,
47
48
  VesselEmission,
48
49
  VesselEmissionsResponse,
50
+ VesselETA,
49
51
  VesselETAResponse,
50
52
  VesselPosition,
51
53
  VesselPositionResponse,
@@ -128,9 +130,9 @@ class VesselsService:
128
130
  error_from_response(r.status_code, r.content)
129
131
  return TypesOwnershipResponse.model_validate(r.json())
130
132
 
131
- def positions(self, *, filter_id_type: str = "imo", filter_ids: str | None = None, pagination_limit: int | None = None, pagination_next_token: str | None = None) -> VesselPositionsResponse:
133
+ def positions(self, *, filter_id_type: str = "imo", filter_ids: str | None = None, time_from: str | None = None, time_to: str | None = None, pagination_limit: int | None = None, pagination_next_token: str | None = None) -> VesselPositionsResponse:
132
134
  """Retrieve positions for multiple vessels."""
133
- r = self._client.get("/vessels/positions", params=_strip_none({"filter.idType": filter_id_type, "filter.ids": filter_ids, "pagination.limit": pagination_limit, "pagination.nextToken": pagination_next_token}))
135
+ r = self._client.get("/vessels/positions", params=_strip_none({"filter.idType": filter_id_type, "filter.ids": filter_ids, "time.from": time_from, "time.to": time_to, "pagination.limit": pagination_limit, "pagination.nextToken": pagination_next_token}))
134
136
  error_from_response(r.status_code, r.content)
135
137
  return VesselPositionsResponse.model_validate(r.json())
136
138
 
@@ -179,6 +181,22 @@ class PortsService:
179
181
  error_from_response(r.status_code, r.content)
180
182
  return PortResponse.model_validate(r.json())
181
183
 
184
+ def inbound(self, unlocode: str, *, eta_from: str, eta_to: str, time_from: str | None = None, time_to: str | None = None, pagination_limit: int | None = None, pagination_next_token: str | None = None) -> PortInboundResponse:
185
+ """Retrieve vessels heading to a port."""
186
+ r = self._client.get(f"/port/{unlocode}/inbound", params=_strip_none({"filter.etaFrom": eta_from, "filter.etaTo": eta_to, "time.from": time_from, "time.to": time_to, "pagination.limit": pagination_limit, "pagination.nextToken": pagination_next_token}))
187
+ error_from_response(r.status_code, r.content)
188
+ return PortInboundResponse.model_validate(r.json())
189
+
190
+ def inbound_all(self, unlocode: str, *, eta_from: str, eta_to: str, time_from: str | None = None, time_to: str | None = None, pagination_limit: int | None = None) -> SyncIterator[VesselETA]:
191
+ """Iterate over all inbound vessels for a port across pages."""
192
+ token: str | None = None
193
+ def fetch() -> tuple[list[VesselETA], str | None]:
194
+ nonlocal token
195
+ resp = self.inbound(unlocode, eta_from=eta_from, eta_to=eta_to, time_from=time_from, time_to=time_to, pagination_limit=pagination_limit, pagination_next_token=token)
196
+ token = resp.next_token
197
+ return resp.vessel_etas or [], token
198
+ return SyncIterator(fetch)
199
+
182
200
 
183
201
  class PortEventsService:
184
202
  """Port event endpoints (sync)."""
@@ -304,15 +322,15 @@ class SearchService:
304
322
  def __init__(self, client: httpx.Client) -> None:
305
323
  self._client = client
306
324
 
307
- def vessels(self, *, filter_name: str | None = None, filter_imo: str | None = None, filter_mmsi: str | None = None, filter_flag: str | None = None, filter_vessel_type: str | None = None, filter_callsign: str | None = None, pagination_limit: int | None = None, pagination_next_token: str | None = None) -> FindVesselsResponse:
325
+ def vessels(self, *, filter_name: str | None = None, filter_imo: str | None = None, filter_mmsi: str | None = None, filter_flag: str | None = None, filter_vessel_type: str | None = None, filter_callsign: str | None = None, filter_year_built_min: int | None = None, filter_year_built_max: int | None = None, filter_class_society: str | None = None, filter_owner: str | None = None, pagination_limit: int | None = None, pagination_next_token: str | None = None) -> FindVesselsResponse:
308
326
  """Search for vessels by name, callsign, flag, type, and other filters."""
309
- r = self._client.get("/search/vessels", params=_strip_none({"filter.name": filter_name, "filter.imo": filter_imo, "filter.mmsi": filter_mmsi, "filter.flag": filter_flag, "filter.vesselType": filter_vessel_type, "filter.callsign": filter_callsign, "pagination.limit": pagination_limit, "pagination.nextToken": pagination_next_token}))
327
+ r = self._client.get("/search/vessels", params=_strip_none({"filter.name": filter_name, "filter.imo": filter_imo, "filter.mmsi": filter_mmsi, "filter.flag": filter_flag, "filter.vesselType": filter_vessel_type, "filter.callsign": filter_callsign, "filter.yearBuiltMin": filter_year_built_min, "filter.yearBuiltMax": filter_year_built_max, "filter.classSociety": filter_class_society, "filter.owner": filter_owner, "pagination.limit": pagination_limit, "pagination.nextToken": pagination_next_token}))
310
328
  error_from_response(r.status_code, r.content)
311
329
  return FindVesselsResponse.model_validate(r.json())
312
330
 
313
- def ports(self, *, filter_name: str | None = None, filter_country: str | None = None, filter_port_type: str | None = None, filter_region: str | None = None, pagination_limit: int | None = None, pagination_next_token: str | None = None) -> FindPortsResponse:
331
+ def ports(self, *, filter_name: str | None = None, filter_country: str | None = None, filter_port_type: str | None = None, filter_size: str | None = None, filter_region: str | None = None, filter_harbor_size: str | None = None, filter_harbor_use: str | None = None, pagination_limit: int | None = None, pagination_next_token: str | None = None) -> FindPortsResponse:
314
332
  """Search for ports by name, country, type, region, and other filters."""
315
- r = self._client.get("/search/ports", params=_strip_none({"filter.name": filter_name, "filter.country": filter_country, "filter.type": filter_port_type, "filter.region": filter_region, "pagination.limit": pagination_limit, "pagination.nextToken": pagination_next_token}))
333
+ r = self._client.get("/search/ports", params=_strip_none({"filter.name": filter_name, "filter.country": filter_country, "filter.type": filter_port_type, "filter.size": filter_size, "filter.region": filter_region, "filter.harborSize": filter_harbor_size, "filter.harborUse": filter_harbor_use, "pagination.limit": pagination_limit, "pagination.nextToken": pagination_next_token}))
316
334
  error_from_response(r.status_code, r.content)
317
335
  return FindPortsResponse.model_validate(r.json())
318
336
 
@@ -409,15 +427,15 @@ class LocationService:
409
427
  def __init__(self, client: httpx.Client) -> None:
410
428
  self._client = client
411
429
 
412
- def vessels_bounding_box(self, *, lat_min: float | None = None, lat_max: float | None = None, lon_min: float | None = None, lon_max: float | None = None, pagination_limit: int | None = None, pagination_next_token: str | None = None) -> VesselsWithinLocationResponse:
430
+ def vessels_bounding_box(self, *, lat_min: float | None = None, lat_max: float | None = None, lon_min: float | None = None, lon_max: float | None = None, time_from: str | None = None, time_to: str | None = None, pagination_limit: int | None = None, pagination_next_token: str | None = None) -> VesselsWithinLocationResponse:
413
431
  """Retrieve vessel positions within a bounding box."""
414
- r = self._client.get("/location/vessels/bounding-box", params=_strip_none({"filter.latBottom": lat_min, "filter.latTop": lat_max, "filter.lonLeft": lon_min, "filter.lonRight": lon_max, "pagination.limit": pagination_limit, "pagination.nextToken": pagination_next_token}))
432
+ r = self._client.get("/location/vessels/bounding-box", params=_strip_none({"filter.latBottom": lat_min, "filter.latTop": lat_max, "filter.lonLeft": lon_min, "filter.lonRight": lon_max, "time.from": time_from, "time.to": time_to, "pagination.limit": pagination_limit, "pagination.nextToken": pagination_next_token}))
415
433
  error_from_response(r.status_code, r.content)
416
434
  return VesselsWithinLocationResponse.model_validate(r.json())
417
435
 
418
- def vessels_radius(self, *, latitude: float | None = None, longitude: float | None = None, radius: float | None = None, pagination_limit: int | None = None, pagination_next_token: str | None = None) -> VesselsWithinLocationResponse:
436
+ def vessels_radius(self, *, latitude: float | None = None, longitude: float | None = None, radius: float | None = None, time_from: str | None = None, time_to: str | None = None, pagination_limit: int | None = None, pagination_next_token: str | None = None) -> VesselsWithinLocationResponse:
419
437
  """Retrieve vessel positions within a radius."""
420
- r = self._client.get("/location/vessels/radius", params=_strip_none({"filter.latitude": latitude, "filter.longitude": longitude, "filter.radius": radius, "pagination.limit": pagination_limit, "pagination.nextToken": pagination_next_token}))
438
+ r = self._client.get("/location/vessels/radius", params=_strip_none({"filter.latitude": latitude, "filter.longitude": longitude, "filter.radius": radius, "time.from": time_from, "time.to": time_to, "pagination.limit": pagination_limit, "pagination.nextToken": pagination_next_token}))
421
439
  error_from_response(r.status_code, r.content)
422
440
  return VesselsWithinLocationResponse.model_validate(r.json())
423
441
 
@@ -692,9 +710,9 @@ class AsyncVesselsService:
692
710
  error_from_response(r.status_code, r.content)
693
711
  return TypesOwnershipResponse.model_validate(r.json())
694
712
 
695
- async def positions(self, *, filter_id_type: str = "imo", filter_ids: str | None = None, pagination_limit: int | None = None, pagination_next_token: str | None = None) -> VesselPositionsResponse:
713
+ async def positions(self, *, filter_id_type: str = "imo", filter_ids: str | None = None, time_from: str | None = None, time_to: str | None = None, pagination_limit: int | None = None, pagination_next_token: str | None = None) -> VesselPositionsResponse:
696
714
  """Retrieve positions for multiple vessels."""
697
- r = await self._client.get("/vessels/positions", params=_strip_none({"filter.idType": filter_id_type, "filter.ids": filter_ids, "pagination.limit": pagination_limit, "pagination.nextToken": pagination_next_token}))
715
+ r = await self._client.get("/vessels/positions", params=_strip_none({"filter.idType": filter_id_type, "filter.ids": filter_ids, "time.from": time_from, "time.to": time_to, "pagination.limit": pagination_limit, "pagination.nextToken": pagination_next_token}))
698
716
  error_from_response(r.status_code, r.content)
699
717
  return VesselPositionsResponse.model_validate(r.json())
700
718
 
@@ -743,6 +761,22 @@ class AsyncPortsService:
743
761
  error_from_response(r.status_code, r.content)
744
762
  return PortResponse.model_validate(r.json())
745
763
 
764
+ async def inbound(self, unlocode: str, *, eta_from: str, eta_to: str, time_from: str | None = None, time_to: str | None = None, pagination_limit: int | None = None, pagination_next_token: str | None = None) -> PortInboundResponse:
765
+ """Retrieve vessels heading to a port."""
766
+ r = await self._client.get(f"/port/{unlocode}/inbound", params=_strip_none({"filter.etaFrom": eta_from, "filter.etaTo": eta_to, "time.from": time_from, "time.to": time_to, "pagination.limit": pagination_limit, "pagination.nextToken": pagination_next_token}))
767
+ error_from_response(r.status_code, r.content)
768
+ return PortInboundResponse.model_validate(r.json())
769
+
770
+ def inbound_all(self, unlocode: str, *, eta_from: str, eta_to: str, time_from: str | None = None, time_to: str | None = None, pagination_limit: int | None = None) -> AsyncIterator[VesselETA]:
771
+ """Iterate over all inbound vessels for a port across pages."""
772
+ token: str | None = None
773
+ async def fetch() -> tuple[list[VesselETA], str | None]:
774
+ nonlocal token
775
+ resp = await self.inbound(unlocode, eta_from=eta_from, eta_to=eta_to, time_from=time_from, time_to=time_to, pagination_limit=pagination_limit, pagination_next_token=token)
776
+ token = resp.next_token
777
+ return resp.vessel_etas or [], token
778
+ return AsyncIterator(fetch)
779
+
746
780
 
747
781
  class AsyncPortEventsService:
748
782
  """Port event endpoints (async)."""
@@ -868,15 +902,15 @@ class AsyncSearchService:
868
902
  def __init__(self, client: httpx.AsyncClient) -> None:
869
903
  self._client = client
870
904
 
871
- async def vessels(self, *, filter_name: str | None = None, filter_imo: str | None = None, filter_mmsi: str | None = None, filter_flag: str | None = None, filter_vessel_type: str | None = None, filter_callsign: str | None = None, pagination_limit: int | None = None, pagination_next_token: str | None = None) -> FindVesselsResponse:
905
+ async def vessels(self, *, filter_name: str | None = None, filter_imo: str | None = None, filter_mmsi: str | None = None, filter_flag: str | None = None, filter_vessel_type: str | None = None, filter_callsign: str | None = None, filter_year_built_min: int | None = None, filter_year_built_max: int | None = None, filter_class_society: str | None = None, filter_owner: str | None = None, pagination_limit: int | None = None, pagination_next_token: str | None = None) -> FindVesselsResponse:
872
906
  """Search for vessels."""
873
- r = await self._client.get("/search/vessels", params=_strip_none({"filter.name": filter_name, "filter.imo": filter_imo, "filter.mmsi": filter_mmsi, "filter.flag": filter_flag, "filter.vesselType": filter_vessel_type, "filter.callsign": filter_callsign, "pagination.limit": pagination_limit, "pagination.nextToken": pagination_next_token}))
907
+ r = await self._client.get("/search/vessels", params=_strip_none({"filter.name": filter_name, "filter.imo": filter_imo, "filter.mmsi": filter_mmsi, "filter.flag": filter_flag, "filter.vesselType": filter_vessel_type, "filter.callsign": filter_callsign, "filter.yearBuiltMin": filter_year_built_min, "filter.yearBuiltMax": filter_year_built_max, "filter.classSociety": filter_class_society, "filter.owner": filter_owner, "pagination.limit": pagination_limit, "pagination.nextToken": pagination_next_token}))
874
908
  error_from_response(r.status_code, r.content)
875
909
  return FindVesselsResponse.model_validate(r.json())
876
910
 
877
- async def ports(self, *, filter_name: str | None = None, filter_country: str | None = None, filter_port_type: str | None = None, filter_region: str | None = None, pagination_limit: int | None = None, pagination_next_token: str | None = None) -> FindPortsResponse:
911
+ async def ports(self, *, filter_name: str | None = None, filter_country: str | None = None, filter_port_type: str | None = None, filter_size: str | None = None, filter_region: str | None = None, filter_harbor_size: str | None = None, filter_harbor_use: str | None = None, pagination_limit: int | None = None, pagination_next_token: str | None = None) -> FindPortsResponse:
878
912
  """Search for ports."""
879
- r = await self._client.get("/search/ports", params=_strip_none({"filter.name": filter_name, "filter.country": filter_country, "filter.type": filter_port_type, "filter.region": filter_region, "pagination.limit": pagination_limit, "pagination.nextToken": pagination_next_token}))
913
+ r = await self._client.get("/search/ports", params=_strip_none({"filter.name": filter_name, "filter.country": filter_country, "filter.type": filter_port_type, "filter.size": filter_size, "filter.region": filter_region, "filter.harborSize": filter_harbor_size, "filter.harborUse": filter_harbor_use, "pagination.limit": pagination_limit, "pagination.nextToken": pagination_next_token}))
880
914
  error_from_response(r.status_code, r.content)
881
915
  return FindPortsResponse.model_validate(r.json())
882
916
 
@@ -973,15 +1007,15 @@ class AsyncLocationService:
973
1007
  def __init__(self, client: httpx.AsyncClient) -> None:
974
1008
  self._client = client
975
1009
 
976
- async def vessels_bounding_box(self, *, lat_min: float | None = None, lat_max: float | None = None, lon_min: float | None = None, lon_max: float | None = None, pagination_limit: int | None = None, pagination_next_token: str | None = None) -> VesselsWithinLocationResponse:
1010
+ async def vessels_bounding_box(self, *, lat_min: float | None = None, lat_max: float | None = None, lon_min: float | None = None, lon_max: float | None = None, time_from: str | None = None, time_to: str | None = None, pagination_limit: int | None = None, pagination_next_token: str | None = None) -> VesselsWithinLocationResponse:
977
1011
  """Retrieve vessel positions within a bounding box."""
978
- r = await self._client.get("/location/vessels/bounding-box", params=_strip_none({"filter.latBottom": lat_min, "filter.latTop": lat_max, "filter.lonLeft": lon_min, "filter.lonRight": lon_max, "pagination.limit": pagination_limit, "pagination.nextToken": pagination_next_token}))
1012
+ r = await self._client.get("/location/vessels/bounding-box", params=_strip_none({"filter.latBottom": lat_min, "filter.latTop": lat_max, "filter.lonLeft": lon_min, "filter.lonRight": lon_max, "time.from": time_from, "time.to": time_to, "pagination.limit": pagination_limit, "pagination.nextToken": pagination_next_token}))
979
1013
  error_from_response(r.status_code, r.content)
980
1014
  return VesselsWithinLocationResponse.model_validate(r.json())
981
1015
 
982
- async def vessels_radius(self, *, latitude: float | None = None, longitude: float | None = None, radius: float | None = None, pagination_limit: int | None = None, pagination_next_token: str | None = None) -> VesselsWithinLocationResponse:
1016
+ async def vessels_radius(self, *, latitude: float | None = None, longitude: float | None = None, radius: float | None = None, time_from: str | None = None, time_to: str | None = None, pagination_limit: int | None = None, pagination_next_token: str | None = None) -> VesselsWithinLocationResponse:
983
1017
  """Retrieve vessel positions within a radius."""
984
- r = await self._client.get("/location/vessels/radius", params=_strip_none({"filter.latitude": latitude, "filter.longitude": longitude, "filter.radius": radius, "pagination.limit": pagination_limit, "pagination.nextToken": pagination_next_token}))
1018
+ r = await self._client.get("/location/vessels/radius", params=_strip_none({"filter.latitude": latitude, "filter.longitude": longitude, "filter.radius": radius, "time.from": time_from, "time.to": time_to, "pagination.limit": pagination_limit, "pagination.nextToken": pagination_next_token}))
985
1019
  error_from_response(r.status_code, r.content)
986
1020
  return VesselsWithinLocationResponse.model_validate(r.json())
987
1021
 
@@ -62,6 +62,58 @@ class TestPortsService:
62
62
  assert result.port.name == "Rotterdam"
63
63
  client.close()
64
64
 
65
+ def test_inbound(self) -> None:
66
+ with respx.mock() as mock:
67
+ route = mock.get("https://api.vesselapi.com/v1/port/NLRTM/inbound").mock(
68
+ return_value=httpx.Response(200, json={
69
+ "vesselETAs": [
70
+ {"imo": 9363728, "vessel_name": "Ever Given", "eta": "2026-03-10T12:00:00Z", "destination_port": "NLRTM"},
71
+ ],
72
+ "nextToken": None,
73
+ })
74
+ )
75
+ client = VesselClient(api_key="key", max_retries=0)
76
+ result = client.ports.inbound("NLRTM", eta_from="2026-03-07T00:00:00Z", eta_to="2026-03-14T00:00:00Z")
77
+ assert result.vessel_etas is not None
78
+ assert len(result.vessel_etas) == 1
79
+ assert result.vessel_etas[0].imo == 9363728
80
+ assert result.vessel_etas[0].vessel_name == "Ever Given"
81
+ # Verify filter params are sent.
82
+ url = str(route.calls[0].request.url)
83
+ assert "filter.etaFrom=2026-03-07" in url
84
+ assert "filter.etaTo=2026-03-14" in url
85
+ client.close()
86
+
87
+
88
+ class TestResolutionMeta:
89
+ """Tests for _meta deserialization on response models."""
90
+
91
+ def test_meta_on_vessel_response(self) -> None:
92
+ with respx.mock() as mock:
93
+ mock.get("https://api.vesselapi.com/v1/vessel/477045900").mock(
94
+ return_value=httpx.Response(200, json={
95
+ "vessel": {"imo": 9363728, "mmsi": 477045900},
96
+ "_meta": {"requestedIdType": "imo", "resolvedIdType": "mmsi", "resolvedId": 477045900},
97
+ })
98
+ )
99
+ client = VesselClient(api_key="key", max_retries=0)
100
+ result = client.vessels.get("477045900", filter_id_type="imo")
101
+ assert result.meta is not None
102
+ assert result.meta.requested_id_type == "imo"
103
+ assert result.meta.resolved_id_type == "mmsi"
104
+ assert result.meta.resolved_id == 477045900
105
+ client.close()
106
+
107
+ def test_meta_absent_when_no_fallback(self) -> None:
108
+ with respx.mock() as mock:
109
+ mock.get("https://api.vesselapi.com/v1/vessel/9363728").mock(
110
+ return_value=httpx.Response(200, json={"vessel": {"imo": 9363728}})
111
+ )
112
+ client = VesselClient(api_key="key", max_retries=0)
113
+ result = client.vessels.get("9363728")
114
+ assert result.meta is None
115
+ client.close()
116
+
65
117
 
66
118
  class TestSearchService:
67
119
  """Tests for the SearchService."""
@@ -95,7 +95,7 @@ class TestSmoke_Vessels:
95
95
 
96
96
 
97
97
  # ---------------------------------------------------------------------------
98
- # Ports (1 subtest)
98
+ # Ports (2 subtests)
99
99
  # ---------------------------------------------------------------------------
100
100
 
101
101
 
@@ -105,6 +105,33 @@ class TestSmoke_Ports:
105
105
  assert resp.port is not None
106
106
  assert resp.port.unlo_code == "NLRTM"
107
107
 
108
+ def test_inbound(self, client: VesselClient) -> None:
109
+ now = datetime.now(timezone.utc)
110
+ eta_from = now.isoformat()
111
+ eta_to = (now + timedelta(days=7)).isoformat()
112
+ resp = client.ports.inbound("NLRTM", eta_from=eta_from, eta_to=eta_to, pagination_limit=5)
113
+ assert resp is not None
114
+
115
+
116
+ class TestSmoke_VesselETA_DestinationPort:
117
+ def test_destination_port(self, client: VesselClient) -> None:
118
+ # CMA CGM KHAO SOK — known to have active ETA with destination_port
119
+ resp = client.vessels.eta("9925837")
120
+ assert resp.vessel_eta is not None
121
+ assert resp.vessel_eta.destination_port is not None
122
+ assert len(resp.vessel_eta.destination_port) > 0
123
+
124
+
125
+ class TestSmoke_Meta_Fallback:
126
+ def test_meta_fallback(self, client: VesselClient) -> None:
127
+ # Use an MMSI value but claim idType=imo to trigger fallback
128
+ resp = client.vessels.get("477045900", filter_id_type="imo")
129
+ assert resp.vessel is not None
130
+ assert resp.meta is not None
131
+ assert resp.meta.requested_id_type == "imo"
132
+ assert resp.meta.resolved_id_type == "mmsi"
133
+ assert resp.meta.resolved_id == 477045900
134
+
108
135
 
109
136
  # ---------------------------------------------------------------------------
110
137
  # PortEvents (9 subtests)