portolan-cli 0.1.3__tar.gz → 0.2.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.
- portolan_cli-0.2.0/.github/SOCIAL_PREVIEW.md +30 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/CHANGELOG.md +6 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/PKG-INFO +8 -5
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/README.md +4 -4
- portolan_cli-0.2.0/context/shared/known-issues/pyarrow-abseil-abi.md +36 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/docs/index.md +5 -1
- portolan_cli-0.2.0/portolan_cli/__init__.py +5 -0
- portolan_cli-0.2.0/portolan_cli/catalog.py +94 -0
- portolan_cli-0.2.0/portolan_cli/cli.py +38 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/pyproject.toml +6 -3
- portolan_cli-0.2.0/tests/unit/test_catalog_init.py +65 -0
- portolan_cli-0.2.0/tests/unit/test_cli_init.py +63 -0
- portolan_cli-0.2.0/uv.lock +3702 -0
- portolan_cli-0.1.3/portolan_cli/cli.py +0 -0
- portolan_cli-0.1.3/uv.lock +0 -1975
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/.claude/hooks/post-bash-remind.sh +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/.claude/hooks/pre-read-check.sh +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/.claude/hooks/prompt-inject.sh +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/.coderabbit.yaml +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/.github/CODEOWNERS +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/.github/dependabot.yml +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/.github/pull_request_template.md +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/.github/workflows/ci.yml +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/.github/workflows/docs.yml +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/.github/workflows/nightly.yml +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/.github/workflows/release.yml +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/.gitignore +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/.pre-commit-config.yaml +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/.python-version +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/CLAUDE.md +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/LICENSE +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/ROADMAP.md +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/SECURITY.md +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/context/architecture.md +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/context/shared/adr/0000-template.md +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/context/shared/adr/0001-agentic-first-development.md +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/context/shared/adr/0002-click-for-cli.md +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/context/shared/adr/0003-plugin-architecture.md +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/context/shared/adr/0004-iceberg-as-plugin.md +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/context/shared/adr/0005-versions-json-source-of-truth.md +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/context/shared/adr/0006-remote-ownership-model.md +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/context/shared/adr/0007-cli-wraps-api.md +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/context/shared/adr/0008-pipx-for-installation.md +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/context/shared/documentation/ci.md +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/context/shared/documentation/distill-mcp.md +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/context/shared/known-issues/example.md +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/docs/BRANDING.md +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/docs/assets/images/cover.png +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/docs/assets/images/icon-white.svg +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/docs/assets/images/icon.svg +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/docs/assets/images/logo.png +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/docs/assets/images/logo.svg +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/docs/assets/images/social-card.png +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/docs/assets/stylesheets/extra.css +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/docs/changelog.md +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/docs/contributing.md +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/docs/roadmap.md +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/mkdocs.yml +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/portolan_cli/output.py +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/tests/conftest.py +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/tests/specs/README.md +0 -0
- {portolan_cli-0.1.3 → portolan_cli-0.2.0}/tests/test_placeholder.py +0 -0
- {portolan_cli-0.1.3/portolan_cli → portolan_cli-0.2.0/tests/unit}/__init__.py +0 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# GitHub Social Preview Setup
|
|
2
|
+
|
|
3
|
+
To configure the repository's social preview image:
|
|
4
|
+
|
|
5
|
+
1. Go to repository **Settings** → **General**
|
|
6
|
+
2. Scroll to **Social preview**
|
|
7
|
+
3. Click **Edit**
|
|
8
|
+
4. Upload the image at `docs/assets/images/social-card.png` (3500x1440px)
|
|
9
|
+
|
|
10
|
+
This will make the Portolan logo and cover image appear when sharing the repository on social media, Slack, Discord, etc.
|
|
11
|
+
|
|
12
|
+
## Files Available
|
|
13
|
+
|
|
14
|
+
- **Logo (square)**: `docs/assets/images/logo.png` (1000x1000)
|
|
15
|
+
- **Logo (vector)**: `docs/assets/images/logo.svg`
|
|
16
|
+
- **Social card**: `docs/assets/images/social-card.png` (3500x1440)
|
|
17
|
+
|
|
18
|
+
## Brand Colors
|
|
19
|
+
|
|
20
|
+
- **Background**: `#eaedf9`
|
|
21
|
+
- **Dark text**: `#202a4f`
|
|
22
|
+
- **Primary**: `#4163cc`
|
|
23
|
+
- **Gradient**: `#395eca` → `#848bd8`
|
|
24
|
+
|
|
25
|
+
## Typography
|
|
26
|
+
|
|
27
|
+
- **Font**: Archivo Medium
|
|
28
|
+
- **Designer**: Omnibus-Type
|
|
29
|
+
- **License**: Open Font License
|
|
30
|
+
- **Icon**: Designed by Icons By Alfredo
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: portolan-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A CLI tool for managing cloud-native geospatial data
|
|
5
5
|
Project-URL: Homepage, https://github.com/portolan-sdi/portolan-cli
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/portolan-sdi/portolan-cli/issues
|
|
@@ -22,6 +22,9 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
22
22
|
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
23
23
|
Requires-Python: >=3.10
|
|
24
24
|
Requires-Dist: click>=8.3.1
|
|
25
|
+
Requires-Dist: geoparquet-io>=0.3.0
|
|
26
|
+
Requires-Dist: pyarrow<22.0.0,>=12.0.0
|
|
27
|
+
Requires-Dist: typing-extensions>=4.0.0; python_version < '3.11'
|
|
25
28
|
Provides-Extra: dev
|
|
26
29
|
Requires-Dist: bandit>=1.9.3; extra == 'dev'
|
|
27
30
|
Requires-Dist: codespell>=2.4.1; extra == 'dev'
|
|
@@ -46,14 +49,14 @@ Requires-Dist: mkdocstrings[python]>=1.0.0; extra == 'docs'
|
|
|
46
49
|
Description-Content-Type: text/markdown
|
|
47
50
|
|
|
48
51
|
<div align="center">
|
|
49
|
-
<img src="docs/assets/images/
|
|
50
|
-
<h1>Portolan CLI</h1>
|
|
51
|
-
<p><strong>Cloud-native geospatial data catalogs, simplified</strong></p>
|
|
52
|
+
<img src="docs/assets/images/cover.png" alt="Portolan" width="600"/>
|
|
52
53
|
</div>
|
|
53
54
|
|
|
54
55
|
---
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
Portolan enables organizations to share geospatial data in a low-cost, accessible, sovereign, and reliable way. Built on [cloud-native geospatial](https://cloudnativegeo.org) formats, a Portolan catalog is as interactive as any geospatial portal—but faster, more scalable, and much cheaper to run. A small government's vector data costs a few dollars a month; even full imagery and point clouds typically stay under $50/month.
|
|
58
|
+
|
|
59
|
+
This CLI converts data to cloud-native formats (GeoParquet, COG), generates rich STAC metadata, and syncs to any object storage—no servers required.
|
|
57
60
|
|
|
58
61
|
## Why Portolan?
|
|
59
62
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<div align="center">
|
|
2
|
-
<img src="docs/assets/images/
|
|
3
|
-
<h1>Portolan CLI</h1>
|
|
4
|
-
<p><strong>Cloud-native geospatial data catalogs, simplified</strong></p>
|
|
2
|
+
<img src="docs/assets/images/cover.png" alt="Portolan" width="600"/>
|
|
5
3
|
</div>
|
|
6
4
|
|
|
7
5
|
---
|
|
8
6
|
|
|
9
|
-
|
|
7
|
+
Portolan enables organizations to share geospatial data in a low-cost, accessible, sovereign, and reliable way. Built on [cloud-native geospatial](https://cloudnativegeo.org) formats, a Portolan catalog is as interactive as any geospatial portal—but faster, more scalable, and much cheaper to run. A small government's vector data costs a few dollars a month; even full imagery and point clouds typically stay under $50/month.
|
|
8
|
+
|
|
9
|
+
This CLI converts data to cloud-native formats (GeoParquet, COG), generates rich STAC metadata, and syncs to any object storage—no servers required.
|
|
10
10
|
|
|
11
11
|
## Why Portolan?
|
|
12
12
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Issue: PyArrow v22+ ABI incompatibility with abseil
|
|
2
|
+
|
|
3
|
+
## Symptom
|
|
4
|
+
|
|
5
|
+
Importing `geoparquet_io` (or any PyArrow-dependent library) fails with:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
ImportError: .../pyarrow/libarrow_substrait.so.2200: undefined symbol: _ZN4absl12lts_2025012718container_internal24GetHashRefForEmptyHasherERKNS1_12CommonFieldsE
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Root cause
|
|
12
|
+
|
|
13
|
+
PyArrow 22.0.0 was compiled against a newer version of the abseil (absl) C++ library than what's available on some Linux systems. The symbol `GetHashRefForEmptyHasher` was added in a recent abseil release, causing a linker failure when the system has an older abseil.
|
|
14
|
+
|
|
15
|
+
This primarily affects:
|
|
16
|
+
- Ubuntu 22.04 and older
|
|
17
|
+
- Systems with system-installed abseil that conflicts with the bundled version
|
|
18
|
+
- Environments where DuckDB or gRPC also link against abseil
|
|
19
|
+
|
|
20
|
+
## Workaround
|
|
21
|
+
|
|
22
|
+
Pin PyArrow to versions before 22.0.0 in `pyproject.toml`:
|
|
23
|
+
|
|
24
|
+
```toml
|
|
25
|
+
"pyarrow>=12.0.0,<22.0.0", # v22+ has ABI incompatibility with abseil on some systems
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## References
|
|
29
|
+
|
|
30
|
+
- PyArrow 22.0.0 release: https://github.com/apache/arrow/releases/tag/apache-arrow-22.0.0
|
|
31
|
+
- Similar issues in DuckDB: https://github.com/duckdb/duckdb/issues
|
|
32
|
+
- abseil ABI stability discussion: https://github.com/abseil/abseil-cpp/issues
|
|
33
|
+
|
|
34
|
+
## Regression test
|
|
35
|
+
|
|
36
|
+
No automated test — this is an environment/binary compatibility issue. Tested manually by verifying `import geoparquet_io` succeeds after pinning.
|
|
@@ -8,7 +8,11 @@ hide:
|
|
|
8
8
|
|
|
9
9
|
**Cloud-native geospatial data catalogs, simplified**
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Portolan enables organizations to share geospatial data in a low-cost, accessible, sovereign, and reliable way. Built on [cloud-native geospatial](https://cloudnativegeo.org) formats, a Portolan catalog is as interactive as any geospatial portal—but faster, more scalable, and much cheaper to run.
|
|
12
|
+
|
|
13
|
+
**Cost examples:** A small government's vector data costs a few dollars a month. Even full imagery and point clouds typically stay under $50/month.
|
|
14
|
+
|
|
15
|
+
This CLI converts data to cloud-native formats (GeoParquet, COG), generates rich STAC metadata, and syncs to any object storage—no servers required.
|
|
12
16
|
|
|
13
17
|
[Get Started](#installation){ .md-button .md-button--primary }
|
|
14
18
|
[View on GitHub](https://github.com/portolan-sdi/portolan-cli){ .md-button }
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Catalog management for Portolan.
|
|
2
|
+
|
|
3
|
+
The Catalog class is the primary interface for working with Portolan catalogs.
|
|
4
|
+
It wraps all catalog operations as methods, following ADR-0007 (CLI wraps API).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
if sys.version_info >= (3, 11):
|
|
14
|
+
from typing import Self
|
|
15
|
+
else:
|
|
16
|
+
from typing_extensions import Self
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CatalogExistsError(Exception):
|
|
20
|
+
"""Raised when attempting to initialize a catalog that already exists."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, path: Path) -> None:
|
|
23
|
+
self.path = path
|
|
24
|
+
super().__init__(f"Catalog already exists at {path}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Catalog:
|
|
28
|
+
"""A Portolan catalog backed by a .portolan directory.
|
|
29
|
+
|
|
30
|
+
The Catalog class provides the Python API for all catalog operations.
|
|
31
|
+
The CLI commands are thin wrappers around these methods.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
root: The root directory containing the .portolan folder.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
PORTOLAN_DIR = ".portolan"
|
|
38
|
+
CATALOG_FILE = "catalog.json"
|
|
39
|
+
STAC_VERSION = "1.0.0"
|
|
40
|
+
|
|
41
|
+
def __init__(self, root: Path) -> None:
|
|
42
|
+
"""Initialize a Catalog instance.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
root: The root directory containing the .portolan folder.
|
|
46
|
+
"""
|
|
47
|
+
self.root = root
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def portolan_path(self) -> Path:
|
|
51
|
+
"""Path to the .portolan directory."""
|
|
52
|
+
return self.root / self.PORTOLAN_DIR
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def catalog_file(self) -> Path:
|
|
56
|
+
"""Path to the catalog.json file."""
|
|
57
|
+
return self.portolan_path / self.CATALOG_FILE
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def init(cls, root: Path) -> Self:
|
|
61
|
+
"""Initialize a new Portolan catalog.
|
|
62
|
+
|
|
63
|
+
Creates the .portolan directory and a minimal STAC catalog.json file.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
root: The directory where the catalog should be created.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
A Catalog instance for the newly created catalog.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
CatalogExistsError: If a .portolan directory already exists.
|
|
73
|
+
"""
|
|
74
|
+
portolan_path = root / cls.PORTOLAN_DIR
|
|
75
|
+
|
|
76
|
+
if portolan_path.exists():
|
|
77
|
+
raise CatalogExistsError(portolan_path)
|
|
78
|
+
|
|
79
|
+
# Create the .portolan directory
|
|
80
|
+
portolan_path.mkdir(parents=True)
|
|
81
|
+
|
|
82
|
+
# Create minimal STAC catalog
|
|
83
|
+
catalog_data = {
|
|
84
|
+
"type": "Catalog",
|
|
85
|
+
"stac_version": cls.STAC_VERSION,
|
|
86
|
+
"id": "portolan-catalog",
|
|
87
|
+
"description": "A Portolan-managed STAC catalog",
|
|
88
|
+
"links": [],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
catalog_file = portolan_path / cls.CATALOG_FILE
|
|
92
|
+
catalog_file.write_text(json.dumps(catalog_data, indent=2))
|
|
93
|
+
|
|
94
|
+
return cls(root)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Portolan CLI - Command-line interface for managing cloud-native geospatial data.
|
|
2
|
+
|
|
3
|
+
The CLI is a thin wrapper around the Python API (see catalog.py).
|
|
4
|
+
All business logic lives in the library; the CLI handles user interaction.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from portolan_cli.catalog import Catalog, CatalogExistsError
|
|
14
|
+
from portolan_cli.output import error, success
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.group()
|
|
18
|
+
@click.version_option()
|
|
19
|
+
def cli() -> None:
|
|
20
|
+
"""Portolan - Publish and manage cloud-native geospatial data catalogs."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@cli.command()
|
|
25
|
+
@click.argument("path", type=click.Path(path_type=Path), default=".")
|
|
26
|
+
def init(path: Path) -> None:
|
|
27
|
+
"""Initialize a new Portolan catalog.
|
|
28
|
+
|
|
29
|
+
Creates a .portolan directory with a STAC catalog.json file.
|
|
30
|
+
|
|
31
|
+
PATH is the directory where the catalog should be created (default: current directory).
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
Catalog.init(path)
|
|
35
|
+
success(f"Initialized Portolan catalog in {path.resolve()}")
|
|
36
|
+
except CatalogExistsError as err:
|
|
37
|
+
error(f"Catalog already exists at {path.resolve()}")
|
|
38
|
+
raise SystemExit(1) from err
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "portolan-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "A CLI tool for managing cloud-native geospatial data"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -27,6 +27,9 @@ classifiers = [
|
|
|
27
27
|
]
|
|
28
28
|
dependencies = [
|
|
29
29
|
"click>=8.3.1",
|
|
30
|
+
"geoparquet-io>=0.3.0",
|
|
31
|
+
"pyarrow>=12.0.0,<22.0.0", # v22+ has ABI incompatibility with abseil on some systems
|
|
32
|
+
"typing_extensions>=4.0.0; python_version < '3.11'",
|
|
30
33
|
]
|
|
31
34
|
|
|
32
35
|
[project.scripts]
|
|
@@ -70,7 +73,7 @@ packages = ["portolan_cli"]
|
|
|
70
73
|
|
|
71
74
|
[tool.commitizen]
|
|
72
75
|
name = "cz_conventional_commits"
|
|
73
|
-
version = "0.
|
|
76
|
+
version = "0.2.0"
|
|
74
77
|
version_files = ["pyproject.toml:^version"]
|
|
75
78
|
tag_format = "v$version"
|
|
76
79
|
update_changelog_on_bump = true
|
|
@@ -160,4 +163,4 @@ skips = [] # Add specific checks to skip if needed, e.g., ["B101"]
|
|
|
160
163
|
min_confidence = 80
|
|
161
164
|
paths = ["portolan_cli", "tests"]
|
|
162
165
|
sort_by_size = true
|
|
163
|
-
verbose = false
|
|
166
|
+
verbose = false
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Tests for Catalog.init() - the Python API for initializing a catalog."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from portolan_cli.catalog import Catalog, CatalogExistsError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestCatalogInit:
|
|
13
|
+
"""Tests for Catalog.init() method."""
|
|
14
|
+
|
|
15
|
+
@pytest.mark.unit
|
|
16
|
+
def test_init_creates_portolan_directory(self, tmp_path: Path) -> None:
|
|
17
|
+
"""init() should create a .portolan directory."""
|
|
18
|
+
Catalog.init(tmp_path)
|
|
19
|
+
|
|
20
|
+
portolan_dir = tmp_path / ".portolan"
|
|
21
|
+
assert portolan_dir.exists()
|
|
22
|
+
assert portolan_dir.is_dir()
|
|
23
|
+
|
|
24
|
+
@pytest.mark.unit
|
|
25
|
+
def test_init_creates_catalog_json(self, tmp_path: Path) -> None:
|
|
26
|
+
"""init() should create a catalog.json file with valid STAC structure."""
|
|
27
|
+
Catalog.init(tmp_path)
|
|
28
|
+
|
|
29
|
+
catalog_file = tmp_path / ".portolan" / "catalog.json"
|
|
30
|
+
assert catalog_file.exists()
|
|
31
|
+
|
|
32
|
+
@pytest.mark.unit
|
|
33
|
+
def test_init_catalog_json_has_required_stac_fields(self, tmp_path: Path) -> None:
|
|
34
|
+
"""catalog.json must have required STAC Catalog fields."""
|
|
35
|
+
import json
|
|
36
|
+
|
|
37
|
+
Catalog.init(tmp_path)
|
|
38
|
+
|
|
39
|
+
catalog_file = tmp_path / ".portolan" / "catalog.json"
|
|
40
|
+
catalog = json.loads(catalog_file.read_text())
|
|
41
|
+
|
|
42
|
+
# Required STAC Catalog fields per spec
|
|
43
|
+
assert catalog["type"] == "Catalog"
|
|
44
|
+
assert catalog["stac_version"] == "1.0.0"
|
|
45
|
+
assert "id" in catalog
|
|
46
|
+
assert "description" in catalog
|
|
47
|
+
assert "links" in catalog
|
|
48
|
+
|
|
49
|
+
@pytest.mark.unit
|
|
50
|
+
def test_init_raises_error_if_catalog_exists(self, tmp_path: Path) -> None:
|
|
51
|
+
"""init() should raise CatalogExistsError if .portolan already exists."""
|
|
52
|
+
# Create existing catalog
|
|
53
|
+
portolan_dir = tmp_path / ".portolan"
|
|
54
|
+
portolan_dir.mkdir()
|
|
55
|
+
|
|
56
|
+
with pytest.raises(CatalogExistsError):
|
|
57
|
+
Catalog.init(tmp_path)
|
|
58
|
+
|
|
59
|
+
@pytest.mark.unit
|
|
60
|
+
def test_init_returns_catalog_instance(self, tmp_path: Path) -> None:
|
|
61
|
+
"""init() should return a Catalog instance for chaining."""
|
|
62
|
+
result = Catalog.init(tmp_path)
|
|
63
|
+
|
|
64
|
+
assert isinstance(result, Catalog)
|
|
65
|
+
assert result.root == tmp_path
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Tests for `portolan init` CLI command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from click.testing import CliRunner
|
|
9
|
+
|
|
10
|
+
from portolan_cli.cli import cli
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestCliInit:
|
|
14
|
+
"""Tests for the `portolan init` CLI command."""
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def runner(self) -> CliRunner:
|
|
18
|
+
"""Create a Click test runner."""
|
|
19
|
+
return CliRunner()
|
|
20
|
+
|
|
21
|
+
@pytest.mark.unit
|
|
22
|
+
def test_init_creates_catalog_in_current_directory(
|
|
23
|
+
self, runner: CliRunner, tmp_path: Path
|
|
24
|
+
) -> None:
|
|
25
|
+
"""portolan init should create .portolan in the current directory."""
|
|
26
|
+
with runner.isolated_filesystem(temp_dir=tmp_path):
|
|
27
|
+
result = runner.invoke(cli, ["init"])
|
|
28
|
+
|
|
29
|
+
assert result.exit_code == 0
|
|
30
|
+
assert Path(".portolan").exists()
|
|
31
|
+
assert Path(".portolan/catalog.json").exists()
|
|
32
|
+
|
|
33
|
+
@pytest.mark.unit
|
|
34
|
+
def test_init_prints_success_message(self, runner: CliRunner, tmp_path: Path) -> None:
|
|
35
|
+
"""portolan init should print a success message."""
|
|
36
|
+
with runner.isolated_filesystem(temp_dir=tmp_path):
|
|
37
|
+
result = runner.invoke(cli, ["init"])
|
|
38
|
+
|
|
39
|
+
assert result.exit_code == 0
|
|
40
|
+
assert "Initialized" in result.output or "✓" in result.output
|
|
41
|
+
|
|
42
|
+
@pytest.mark.unit
|
|
43
|
+
def test_init_fails_if_catalog_exists(self, runner: CliRunner, tmp_path: Path) -> None:
|
|
44
|
+
"""portolan init should fail if .portolan already exists."""
|
|
45
|
+
with runner.isolated_filesystem(temp_dir=tmp_path):
|
|
46
|
+
Path(".portolan").mkdir()
|
|
47
|
+
|
|
48
|
+
result = runner.invoke(cli, ["init"])
|
|
49
|
+
|
|
50
|
+
assert result.exit_code != 0
|
|
51
|
+
assert "already exists" in result.output.lower()
|
|
52
|
+
|
|
53
|
+
@pytest.mark.unit
|
|
54
|
+
def test_init_accepts_path_argument(self, runner: CliRunner, tmp_path: Path) -> None:
|
|
55
|
+
"""portolan init PATH should create catalog at specified path."""
|
|
56
|
+
with runner.isolated_filesystem(temp_dir=tmp_path):
|
|
57
|
+
target = Path("my-catalog")
|
|
58
|
+
target.mkdir()
|
|
59
|
+
|
|
60
|
+
result = runner.invoke(cli, ["init", str(target)])
|
|
61
|
+
|
|
62
|
+
assert result.exit_code == 0
|
|
63
|
+
assert (target / ".portolan").exists()
|