Skip to content

Commit 08ee417

Browse files
committed
Prepare package for PyPI release
1 parent b65175b commit 08ee417

10 files changed

Lines changed: 162 additions & 105 deletions

File tree

.github/workflows/publish.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: Publish to PyPI
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
workflow_dispatch:
8+
9+
jobs:
10+
build:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- name: Check out repository
15+
uses: actions/checkout@v4
16+
17+
- name: Set up Python
18+
uses: actions/setup-python@v5
19+
with:
20+
python-version: "3.12"
21+
22+
- name: Install build tools
23+
run: python -m pip install --upgrade build twine
24+
25+
- name: Build distributions
26+
run: python -m build
27+
28+
- name: Check distributions
29+
run: python -m twine check dist/*
30+
31+
- name: Upload distributions
32+
uses: actions/upload-artifact@v4
33+
with:
34+
name: python-package-distributions
35+
path: dist/
36+
37+
publish:
38+
needs: build
39+
runs-on: ubuntu-latest
40+
environment:
41+
name: pypi
42+
permissions:
43+
id-token: write
44+
45+
steps:
46+
- name: Download distributions
47+
uses: actions/download-artifact@v4
48+
with:
49+
name: python-package-distributions
50+
path: dist/
51+
52+
- name: Publish to PyPI
53+
uses: pypa/gh-action-pypi-publish@release/v1

README.md

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
[![PyPI version](https://img.shields.io/pypi/v/paldefender-rest-client)](https://pypi.org/project/paldefender-rest-client/)
2+
[![Python versions](https://img.shields.io/pypi/pyversions/paldefender-rest-client)](https://pypi.org/project/paldefender-rest-client/)
3+
[![License](https://img.shields.io/pypi/l/paldefender-rest-client)](https://github.com/PalLink/PD-REST-Python/blob/main/LICENSE)
4+
[![Discord](https://img.shields.io/discord/1399150174357422150?style=social&logo=discord&label=Discord)](https://discord.gg/ZfHEeGbxbk)
5+
16
# PalDefender-REST-Client (Python)
27

38
Installable Python client for the PalDefender REST API. The package covers every route currently registered in the PalDefender reference implementation under `Reference/`.
49

510
## Install
611

712
```bash
8-
pip install .
13+
pip install paldefender-rest-client
914
```
1015

1116
## Quick Start
@@ -28,16 +33,20 @@ Or:
2833
import PalDefender
2934
```
3035

31-
## Example Project
36+
## CLI
3237

33-
A runnable example CLI is included in [examples/paldefender_cli](examples/paldefender_cli). It uses a local `.env` file for credentials and server settings, and covers every client endpoint.
38+
The package also installs a `paldefender-cli` command.
3439

3540
```bash
36-
pip install -e .[examples]
37-
python examples/paldefender_cli/main.py version
41+
paldefender-cli --env .env version
3842
```
3943

40-
Copy `examples/paldefender_cli/.env.example` to `examples/paldefender_cli/.env`, then fill in your settings before running the example.
44+
Set these variables in `.env` or in your shell environment:
45+
46+
- `PALDEFENDER_BASE_URL`
47+
- `PALDEFENDER_BEARER_TOKEN`
48+
- `PALDEFENDER_DISPLAY_ADDRESS` (optional)
49+
- `PALDEFENDER_TIMEOUT` (optional)
4150

4251
If `base_url` omits a port, the client defaults to `17993`.
4352

@@ -101,8 +110,8 @@ HTTP errors raise `PalDefenderApiError`. The exception exposes:
101110

102111
## Endpoint Reference
103112

104-
Full endpoint documentation is in [docs/ENDPOINTS.md](docs/ENDPOINTS.md).
113+
Full endpoint documentation is in [docs/ENDPOINTS.md](https://github.com/PalLink/PD-REST-Python/blob/main/docs/ENDPOINTS.md).
105114

106115
## Usage Guide
107116

108-
For a more detailed Python-focused guide with examples for typed GET responses, friendly POST helpers, constants, recipes, and error handling, see [docs/USAGE_GUIDE.md](docs/USAGE_GUIDE.md).
117+
For a more detailed Python-focused guide with examples for typed GET responses, friendly POST helpers, constants, recipes, and error handling, see [docs/USAGE_GUIDE.md](https://github.com/PalLink/PD-REST-Python/blob/main/docs/USAGE_GUIDE.md).

docs/USAGE_GUIDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ pip install .
1414
For the example CLI:
1515

1616
```bash
17-
pip install -e .[examples]
17+
pip install -e .
1818
```
1919

2020
## Creating a Client
@@ -398,7 +398,7 @@ except PalDefenderApiError as exc:
398398

399399
## Full Example CLI
400400

401-
There is also a runnable CLI example in [examples/paldefender_cli](../examples/paldefender_cli).
401+
The package also installs a `paldefender-cli` command for terminal use.
402402

403403
It is useful when you want to:
404404

examples/paldefender_cli/.env.example

Lines changed: 0 additions & 4 deletions
This file was deleted.

examples/paldefender_cli/README.md

Lines changed: 0 additions & 68 deletions
This file was deleted.

pyproject.toml

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,21 @@ classifiers = [
2525
"Topic :: Software Development :: Libraries :: Python Modules"
2626
]
2727
dependencies = [
28+
"python-dotenv>=1.0.1,<2",
2829
"requests>=2.31.0,<3"
2930
]
3031

31-
[project.optional-dependencies]
32-
examples = [
33-
"python-dotenv>=1.0.1,<2"
34-
]
32+
[project.scripts]
33+
paldefender-cli = "paldefender_cli.cli:main"
3534

3635
[project.urls]
37-
Homepage = "https://github.com/"
38-
Documentation = "https://github.com/"
36+
Homepage = "https://pallink.net"
37+
Repository = "https://github.com/PalLink/PD-REST-Python"
38+
Documentation = "https://github.com/PalLink/PD-REST-Python/blob/main/docs/USAGE_GUIDE.md"
39+
Issues = "https://github.com/PalLink/PD-REST-Python/issues"
3940

4041
[tool.hatch.build.targets.wheel]
41-
packages = ["src/PalDefender"]
42+
packages = ["src/PalDefender", "src/paldefender_cli"]
4243

4344
[tool.hatch.build.targets.sdist]
4445
include = [

src/paldefender_cli/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Command-line interface for the PalDefender REST client."""
2+
3+
from .cli import main
4+
5+
__all__ = ["main"]

src/paldefender_cli/__main__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .cli import main
2+
3+
raise SystemExit(main())
Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,16 @@
77
from pathlib import Path
88
from typing import Any
99

10-
from dotenv import load_dotenv
10+
try:
11+
from dotenv import load_dotenv as _load_dotenv
12+
except ModuleNotFoundError:
13+
def _load_dotenv(dotenv_path: Path) -> None:
14+
for raw_line in dotenv_path.read_text(encoding="utf-8").splitlines():
15+
line = raw_line.strip()
16+
if not line or line.startswith("#") or "=" not in line:
17+
continue
18+
key, value = line.split("=", 1)
19+
os.environ.setdefault(key.strip(), value.strip())
1120

1221
from PalDefender import (
1322
GiveItem,
@@ -22,7 +31,11 @@
2231

2332
def build_parser() -> argparse.ArgumentParser:
2433
parser = argparse.ArgumentParser(
25-
description="Full example CLI for the PalDefender REST client.",
34+
description="Command-line interface for the PalDefender REST client.",
35+
)
36+
parser.add_argument(
37+
"--env",
38+
help="Path to a .env file. Defaults to .env in the current working directory when present.",
2639
)
2740
subparsers = parser.add_subparsers(dest="command", required=True)
2841

@@ -109,18 +122,30 @@ def build_parser() -> argparse.ArgumentParser:
109122
return parser
110123

111124

112-
def load_settings(env_path: Path) -> dict[str, Any]:
113-
load_dotenv(env_path)
125+
def resolve_env_path(raw_env_path: str | None) -> Path | None:
126+
if raw_env_path:
127+
return Path(raw_env_path).expanduser().resolve()
128+
129+
default_env_path = Path.cwd() / ".env"
130+
if default_env_path.is_file():
131+
return default_env_path
132+
return None
133+
134+
135+
def load_settings(env_path: Path | None) -> dict[str, Any]:
136+
if env_path is not None:
137+
_load_dotenv(env_path)
114138

115139
base_url = os.getenv("PALDEFENDER_BASE_URL", "").strip()
116140
bearer_token = os.getenv("PALDEFENDER_BEARER_TOKEN", "").strip()
117141
display_address = os.getenv("PALDEFENDER_DISPLAY_ADDRESS", "").strip() or None
118142
timeout_raw = os.getenv("PALDEFENDER_TIMEOUT", "30").strip()
119143

144+
source = str(env_path) if env_path is not None else "environment variables"
120145
if not base_url:
121-
raise ValueError(f"PALDEFENDER_BASE_URL is required in {env_path}")
146+
raise ValueError(f"PALDEFENDER_BASE_URL is required in {source}")
122147
if not bearer_token:
123-
raise ValueError(f"PALDEFENDER_BEARER_TOKEN is required in {env_path}")
148+
raise ValueError(f"PALDEFENDER_BEARER_TOKEN is required in {source}")
124149

125150
try:
126151
timeout = float(timeout_raw)
@@ -224,9 +249,7 @@ def print_json(data: Any) -> None:
224249
def main() -> int:
225250
parser = build_parser()
226251
args = parser.parse_args()
227-
228-
example_dir = Path(__file__).resolve().parent
229-
env_path = example_dir / ".env"
252+
env_path = resolve_env_path(args.env)
230253

231254
try:
232255
settings = load_settings(env_path)
@@ -284,7 +307,3 @@ def main() -> int:
284307

285308
print_json(result)
286309
return 0
287-
288-
289-
if __name__ == "__main__":
290-
raise SystemExit(main())

tests/test_paldefender_cli.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
from __future__ import annotations
22

3+
import os
34
import sys
4-
import types
55
import unittest
66
from pathlib import Path
7+
from tempfile import TemporaryDirectory
78

89
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
9-
sys.modules.setdefault("dotenv", types.SimpleNamespace(load_dotenv=lambda *_args, **_kwargs: None))
1010

11-
from examples.paldefender_cli.main import load_json_argument, parse_items
11+
from paldefender_cli.cli import load_json_argument, load_settings, parse_items, resolve_env_path
1212

1313

1414
class PalDefenderCliTests(unittest.TestCase):
@@ -29,6 +29,45 @@ def test_parse_items_maps_known_item_shape(self) -> None:
2929
self.assertEqual(len(parsed), 1)
3030
self.assertEqual(parsed[0].to_dict(), {"ItemID": "Axe", "Count": 7})
3131

32+
def test_resolve_env_path_prefers_explicit_path(self) -> None:
33+
resolved = resolve_env_path("./custom.env")
34+
35+
self.assertTrue(resolved is not None)
36+
self.assertEqual(resolved.name, "custom.env")
37+
38+
def test_load_settings_reads_values_from_env_file(self) -> None:
39+
with TemporaryDirectory() as tmp_dir:
40+
env_path = Path(tmp_dir) / ".env"
41+
env_path.write_text(
42+
"PALDEFENDER_BASE_URL=http://localhost\n"
43+
"PALDEFENDER_BEARER_TOKEN=secret\n"
44+
"PALDEFENDER_TIMEOUT=45\n",
45+
encoding="utf-8",
46+
)
47+
48+
old_base_url = os.environ.pop("PALDEFENDER_BASE_URL", None)
49+
old_bearer_token = os.environ.pop("PALDEFENDER_BEARER_TOKEN", None)
50+
old_timeout = os.environ.pop("PALDEFENDER_TIMEOUT", None)
51+
try:
52+
settings = load_settings(env_path)
53+
finally:
54+
if old_base_url is not None:
55+
os.environ["PALDEFENDER_BASE_URL"] = old_base_url
56+
else:
57+
os.environ.pop("PALDEFENDER_BASE_URL", None)
58+
if old_bearer_token is not None:
59+
os.environ["PALDEFENDER_BEARER_TOKEN"] = old_bearer_token
60+
else:
61+
os.environ.pop("PALDEFENDER_BEARER_TOKEN", None)
62+
if old_timeout is not None:
63+
os.environ["PALDEFENDER_TIMEOUT"] = old_timeout
64+
else:
65+
os.environ.pop("PALDEFENDER_TIMEOUT", None)
66+
67+
self.assertEqual(settings["base_url"], "http://localhost")
68+
self.assertEqual(settings["bearer_token"], "secret")
69+
self.assertEqual(settings["timeout"], 45.0)
70+
3271

3372
if __name__ == "__main__":
3473
unittest.main()

0 commit comments

Comments
 (0)