brewcli 0.1.0__py3-none-any.whl
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.
- brewcli/__init__.py +0 -0
- brewcli/__main__.py +4 -0
- brewcli/brewery.py +120 -0
- brewcli/cli.py +112 -0
- brewcli/logging.py +20 -0
- brewcli/logging_config.json +0 -0
- brewcli/models.py +392 -0
- brewcli/utils.py +3 -0
- brewcli-0.1.0.dist-info/METADATA +132 -0
- brewcli-0.1.0.dist-info/RECORD +13 -0
- brewcli-0.1.0.dist-info/WHEEL +4 -0
- brewcli-0.1.0.dist-info/entry_points.txt +2 -0
- brewcli-0.1.0.dist-info/licenses/LICENSE +21 -0
brewcli/__init__.py
ADDED
|
File without changes
|
brewcli/__main__.py
ADDED
brewcli/brewery.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""This module contains functions for calling Open Brewery DB API"""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from brewcli.models import SearchQuery
|
|
8
|
+
|
|
9
|
+
BASE_URL = "https://api.openbrewerydb.org/v1/breweries"
|
|
10
|
+
HEADERS = {
|
|
11
|
+
"Accept": "application/json",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BreweryAPI:
|
|
16
|
+
"""
|
|
17
|
+
A class to interact with the Open Brewery DB API using httpx.
|
|
18
|
+
|
|
19
|
+
This class provides methods to perform API requests, such as fetching
|
|
20
|
+
random breweries and getting details for a specific brewery by ID.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, base_url: str = BASE_URL):
|
|
24
|
+
"""
|
|
25
|
+
Initializes the BreweryAPI object with the base URL.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
base_url (str): The base URL for the API. Defaults to Open Brewery DB URL.
|
|
29
|
+
"""
|
|
30
|
+
self.base_url: str = base_url
|
|
31
|
+
self.client: httpx.Client
|
|
32
|
+
self.headers = HEADERS
|
|
33
|
+
|
|
34
|
+
def __enter__(self) -> "BreweryAPI":
|
|
35
|
+
"""Initializes the HTTP client when entering the context."""
|
|
36
|
+
self.client = httpx.Client(headers=self.headers)
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
|
40
|
+
"""Ensures the HTTP client is closed when exiting the context."""
|
|
41
|
+
if self.client:
|
|
42
|
+
self.client.close()
|
|
43
|
+
|
|
44
|
+
def _handle_request(
|
|
45
|
+
self, endpoint: str | None = None, params: dict | None = None
|
|
46
|
+
) -> Any:
|
|
47
|
+
"""
|
|
48
|
+
Internal method to handle GET requests to the API.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
endpoint (str): The API endpoint to call.
|
|
52
|
+
params (dict): Any query parameters to include in the request.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Any: The JSON response from the API.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
httpx.HTTPError: If the request fails.
|
|
59
|
+
ValueError: If the response cannot be parsed as JSON.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
url = f"{self.base_url}/{endpoint}" if endpoint else self.base_url
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
response = self.client.get(url, params=params)
|
|
66
|
+
response.raise_for_status()
|
|
67
|
+
except httpx.HTTPError as exc:
|
|
68
|
+
raise httpx.HTTPError(f"Error while requesting {url}.") from exc
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
return response.json()
|
|
72
|
+
except ValueError as exc:
|
|
73
|
+
raise ValueError(f"Failed to return json response from {url}") from exc
|
|
74
|
+
|
|
75
|
+
def get_random_breweries(self, number: int = 1) -> Any:
|
|
76
|
+
"""
|
|
77
|
+
Fetches a specified number of random breweries from the Open Brewery DB API.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
number (int): The number of random brewery results to return. Defaults to 1.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
list[dict]: A list of brewery details as dictionaries.
|
|
84
|
+
"""
|
|
85
|
+
return self._handle_request(endpoint="random", params={"size": number})
|
|
86
|
+
|
|
87
|
+
def get_brewery_by_id(self, brewery_id: str) -> Any:
|
|
88
|
+
"""
|
|
89
|
+
Fetches a single brewery by its ID.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
brewery_id (str): The ID of the brewery to fetch.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
dict: The brewery details.
|
|
96
|
+
"""
|
|
97
|
+
return self._handle_request(brewery_id)
|
|
98
|
+
|
|
99
|
+
def get_brewery_filters(self, search_query: SearchQuery) -> Any:
|
|
100
|
+
"""
|
|
101
|
+
Fetches a list of breweries based on the specified search query filters.
|
|
102
|
+
|
|
103
|
+
This method constructs the necessary query parameters from the provided
|
|
104
|
+
`SearchQuery` object and sends a request to the Open Brewery DB API to
|
|
105
|
+
retrieve breweries that match the search criteria.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
search_query (SearchQuery): An object containing search filters to be
|
|
109
|
+
applied to the brewery search.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Any: The JSON response from the API, typically a list of breweries
|
|
113
|
+
that match the search query.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
httpx.HTTPError: If the request to the API fails.
|
|
117
|
+
ValueError: If the response cannot be parsed as JSON.
|
|
118
|
+
"""
|
|
119
|
+
params = search_query.to_params()
|
|
120
|
+
return self._handle_request(params=params)
|
brewcli/cli.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from httpx import HTTPError
|
|
3
|
+
|
|
4
|
+
from .brewery import BreweryAPI
|
|
5
|
+
from .models import BREWERY_TYPES, Brewery, Coordinate, SearchQuery
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.group()
|
|
9
|
+
def cli() -> None:
|
|
10
|
+
"""
|
|
11
|
+
A simple CLI that retrieves random breweries and displays their name, location,
|
|
12
|
+
and a link to their website.
|
|
13
|
+
|
|
14
|
+
Provide a number specifying how many breweries you would like!
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@cli.command()
|
|
19
|
+
@click.argument("number", type=click.IntRange(min=1))
|
|
20
|
+
def random(number: int) -> None:
|
|
21
|
+
"""
|
|
22
|
+
Retrieve a random set of breweries.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
number (int): The number of random breweries to retrieve.
|
|
26
|
+
"""
|
|
27
|
+
with BreweryAPI() as client:
|
|
28
|
+
try:
|
|
29
|
+
breweries: list[Brewery] = [
|
|
30
|
+
Brewery.from_dict(brewery)
|
|
31
|
+
for brewery in client.get_random_breweries(number=number)
|
|
32
|
+
]
|
|
33
|
+
except HTTPError as exc:
|
|
34
|
+
click.echo(f"HTTP Exception for {exc.request.url} - {exc}", err=True)
|
|
35
|
+
return
|
|
36
|
+
except (KeyError, TypeError) as exc:
|
|
37
|
+
click.echo(
|
|
38
|
+
f"Error occurred while instantiating Brewery from response data: {exc}",
|
|
39
|
+
err=True,
|
|
40
|
+
)
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
for brewery in breweries:
|
|
44
|
+
click.echo(brewery)
|
|
45
|
+
click.echo()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@cli.command()
|
|
49
|
+
@click.argument("brewery_id", type=click.STRING)
|
|
50
|
+
def by_id(brewery_id: str) -> None:
|
|
51
|
+
"""Retrieve a brewery by ID"""
|
|
52
|
+
with BreweryAPI() as client:
|
|
53
|
+
try:
|
|
54
|
+
data: dict = client.get_brewery_by_id(brewery_id=brewery_id)
|
|
55
|
+
brewery: Brewery = Brewery.from_dict(data)
|
|
56
|
+
except (KeyError, TypeError) as exc:
|
|
57
|
+
click.echo(f"Error occurred creating Brewery from response data: {exc}")
|
|
58
|
+
return
|
|
59
|
+
except HTTPError as exc:
|
|
60
|
+
click.echo(f"HTTP Exception for {exc.request.url} - {exc}", err=True)
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
click.echo(brewery)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@cli.command()
|
|
67
|
+
@click.option("--by-city", type=click.STRING)
|
|
68
|
+
@click.option("--by-country", type=click.STRING)
|
|
69
|
+
@click.option("--by-dist", type=click.STRING, help="Coordinates as 'lat,lon'")
|
|
70
|
+
@click.option("--by-name", type=click.STRING)
|
|
71
|
+
@click.option("--by-postal", type=click.STRING)
|
|
72
|
+
@click.option("--by-state", type=click.STRING)
|
|
73
|
+
@click.option("--by-type", type=click.Choice(BREWERY_TYPES, case_sensitive=False))
|
|
74
|
+
def search(**filters: str | None) -> None:
|
|
75
|
+
"""Retrieve a set of breweries using one or more search terms."""
|
|
76
|
+
by_dist = filters.pop("by_dist")
|
|
77
|
+
coord = None
|
|
78
|
+
if by_dist:
|
|
79
|
+
try:
|
|
80
|
+
coord = Coordinate.from_str(by_dist)
|
|
81
|
+
except ValueError as exc:
|
|
82
|
+
click.echo(f"Invalid --by-dist value: {exc}", err=True)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
# Map remaining --by-* options (e.g. by_city) to SearchQuery fields (city).
|
|
86
|
+
fields = {key.removeprefix("by_"): value for key, value in filters.items()}
|
|
87
|
+
query = SearchQuery(coord=coord, **fields)
|
|
88
|
+
|
|
89
|
+
with BreweryAPI() as client:
|
|
90
|
+
try:
|
|
91
|
+
results = client.get_brewery_filters(query)
|
|
92
|
+
except HTTPError as exc:
|
|
93
|
+
click.echo(f"HTTP Exception: {exc}", err=True)
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
if not results:
|
|
97
|
+
click.echo("No breweries found.")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
for data in results:
|
|
101
|
+
try:
|
|
102
|
+
brewery = Brewery.from_dict(data)
|
|
103
|
+
except (KeyError, TypeError) as exc:
|
|
104
|
+
click.echo(f"Error parsing brewery: {exc}", err=True)
|
|
105
|
+
continue
|
|
106
|
+
click.echo(brewery)
|
|
107
|
+
click.echo()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
cli.add_command(random)
|
|
111
|
+
cli.add_command(by_id)
|
|
112
|
+
cli.add_command(search)
|
brewcli/logging.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
BASE_DIR = Path(__file__).parent
|
|
6
|
+
CONFIG_PATH = BASE_DIR / "logging_config.json"
|
|
7
|
+
LOG_FILE_PATH = BASE_DIR / "brewcli.log"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def setup_logging():
|
|
11
|
+
"""Setup logging from a JSON config file."""
|
|
12
|
+
with CONFIG_PATH.open("r") as f:
|
|
13
|
+
config = json.load(f)
|
|
14
|
+
|
|
15
|
+
# Ensures the log file directory exists
|
|
16
|
+
LOG_FILE_PATH.touch(exist_ok=True)
|
|
17
|
+
|
|
18
|
+
# Set up Queues for async logging
|
|
19
|
+
|
|
20
|
+
logging.config.dictConfig(config)
|
|
File without changes
|
brewcli/models.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Module providing data objects"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass, fields
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Coordinate:
|
|
12
|
+
"""
|
|
13
|
+
Represents a geographic coordinate with latitude and longitude values.
|
|
14
|
+
|
|
15
|
+
This class ensures that both latitude and longitude values are float-compatible and
|
|
16
|
+
within the valid range of -180 to 180.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
latitude (float): The latitude of the coordinate,
|
|
20
|
+
a float value in the range [-180, 180].
|
|
21
|
+
longitude (float): The longitude of the coordinate,
|
|
22
|
+
a float value in the range [-180, 180].
|
|
23
|
+
|
|
24
|
+
Methods:
|
|
25
|
+
to_str() -> str: Returns a string representation of the coordinate in the
|
|
26
|
+
format "<latitude>,<longitude>".
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
ValueError: If the latitude or longitude is not convertible to a float or is
|
|
30
|
+
out of the valid range.
|
|
31
|
+
TypeError: If the latitude or longitude is of type `None`.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
latitude: float
|
|
35
|
+
longitude: float
|
|
36
|
+
|
|
37
|
+
def __post_init__(self):
|
|
38
|
+
for f in fields(self):
|
|
39
|
+
value = getattr(self, f.name)
|
|
40
|
+
try:
|
|
41
|
+
value = float(value)
|
|
42
|
+
setattr(self, f.name, value)
|
|
43
|
+
except ValueError as exc:
|
|
44
|
+
raise ValueError(f"Cannot convert {f.name}={value!r} to float") from exc
|
|
45
|
+
except TypeError as exc:
|
|
46
|
+
raise TypeError(f"Cannot accept NoneType {f.name}={value!r}") from exc
|
|
47
|
+
if abs(value) > 180:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
"Coordinate values must be within interval [-180, 180]"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def to_str(self) -> str:
|
|
53
|
+
"""
|
|
54
|
+
Returns a string representation of the coordinate.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
str: A string in the format "<latitude>,<longitude>".
|
|
58
|
+
|
|
59
|
+
Example:
|
|
60
|
+
>>> coord = Coordinate(latitude=45.0, longitude=-93.0)
|
|
61
|
+
>>> coord.to_str()
|
|
62
|
+
'45.0,-93.0'
|
|
63
|
+
"""
|
|
64
|
+
return f"{self.latitude},{self.longitude}"
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_str(cls, coordinate: str):
|
|
68
|
+
"""
|
|
69
|
+
Creates an instance from a string containing the latitude and longitude
|
|
70
|
+
separated by a comma.
|
|
71
|
+
|
|
72
|
+
Attributes:
|
|
73
|
+
coordinate (str): String representing a coordinate of the
|
|
74
|
+
form "<latitude>,<longitude>"
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
# Try to split on comma and should have exactly 2 elements
|
|
78
|
+
# Trim whitespace
|
|
79
|
+
# Try to convert each element to a float
|
|
80
|
+
# If successfully converted to floats return a Coordinate object
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
lat_long = [s.strip() for s in coordinate.split(",")]
|
|
84
|
+
|
|
85
|
+
if len(lat_long) != 2:
|
|
86
|
+
raise ValueError("Input must be in the format '<latitude>,<longitude>'")
|
|
87
|
+
|
|
88
|
+
lat, long = float(lat_long[0]), float(lat_long[1])
|
|
89
|
+
|
|
90
|
+
return cls(lat, long)
|
|
91
|
+
|
|
92
|
+
except ValueError as exc:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"Invalid coordinate format: {coordinate}. Error: {exc}"
|
|
95
|
+
) from exc
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class Address:
|
|
100
|
+
"""
|
|
101
|
+
Represents a physical address and its associated geographic coordinate.
|
|
102
|
+
|
|
103
|
+
This class contains attributes to define a complete address and an optional
|
|
104
|
+
geographic coordinate (latitude and longitude).
|
|
105
|
+
|
|
106
|
+
Attributes:
|
|
107
|
+
address_one (str): The primary address line.
|
|
108
|
+
address_two (str | None): The secondary address line, if any.
|
|
109
|
+
address_three (str | None): The tertiary address line, if any.
|
|
110
|
+
street (str): The street name.
|
|
111
|
+
city (str): The city where the address is located.
|
|
112
|
+
state (str): The state or province of the address.
|
|
113
|
+
postal_code (str): The postal or ZIP code.
|
|
114
|
+
country (str): The country of the address.
|
|
115
|
+
coordinate (Coordinate | None): The geographic coordinate of the address,
|
|
116
|
+
represented as a `Coordinate` object. Can be `None` if not provided.
|
|
117
|
+
|
|
118
|
+
Methods:
|
|
119
|
+
from_dict(data: dict) -> Address:
|
|
120
|
+
Creates an `Address` object from a dictionary of attributes.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
address_one: str
|
|
124
|
+
street: str
|
|
125
|
+
city: str
|
|
126
|
+
state: str
|
|
127
|
+
postal_code: str
|
|
128
|
+
country: str
|
|
129
|
+
address_two: str | None = None
|
|
130
|
+
address_three: str | None = None
|
|
131
|
+
coordinate: Coordinate | None = None
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def from_dict(cls, data: dict):
|
|
135
|
+
"""
|
|
136
|
+
Create and Address object from dictionary.
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
coordinate = Coordinate(
|
|
140
|
+
longitude=data["longitude"], latitude=data["latitude"]
|
|
141
|
+
)
|
|
142
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
143
|
+
logger.warning(
|
|
144
|
+
"Could not create coordinate for %s: %s", data.get("name"), exc
|
|
145
|
+
)
|
|
146
|
+
coordinate = None
|
|
147
|
+
return cls(
|
|
148
|
+
address_one=data["address_1"],
|
|
149
|
+
address_two=data.get("address_2"),
|
|
150
|
+
address_three=data.get("address_3"),
|
|
151
|
+
street=data["street"],
|
|
152
|
+
city=data["city"],
|
|
153
|
+
state=data["state"],
|
|
154
|
+
postal_code=data["postal_code"],
|
|
155
|
+
country=data["country"],
|
|
156
|
+
coordinate=coordinate,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class Brewery:
|
|
162
|
+
"""
|
|
163
|
+
Represents a brewery and its associated details.
|
|
164
|
+
|
|
165
|
+
This class encapsulates information about a brewery, including its ID, name,
|
|
166
|
+
address, contact details, and website URL.
|
|
167
|
+
|
|
168
|
+
Attributes:
|
|
169
|
+
id (str): The unique identifier of the brewery.
|
|
170
|
+
name (str): The name of the brewery.
|
|
171
|
+
address (Address): The address of the brewery, represented as an `Address`
|
|
172
|
+
object.
|
|
173
|
+
phone (str): The phone number of the brewery.
|
|
174
|
+
website_url (str): The website URL of the brewery.
|
|
175
|
+
|
|
176
|
+
Methods:
|
|
177
|
+
from_dict(data: dict) -> Brewery:
|
|
178
|
+
Creates a `Brewery` object from a dictionary of attributes.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
id: str
|
|
182
|
+
name: str
|
|
183
|
+
brewery_type: str
|
|
184
|
+
address: Address
|
|
185
|
+
phone: str | None
|
|
186
|
+
website_url: str | None
|
|
187
|
+
|
|
188
|
+
@classmethod
|
|
189
|
+
def from_dict(cls, data: dict) -> "Brewery":
|
|
190
|
+
"""
|
|
191
|
+
Creates a `Brewery` object from a dictionary.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
data (dict): A dictionary containing the brewery data. Expected
|
|
195
|
+
keys include:
|
|
196
|
+
- "id" (str): The brewery's unique identifier.
|
|
197
|
+
- "name" (str): The brewery's name.
|
|
198
|
+
- "brewery_type" (str): Type of brewery.
|
|
199
|
+
- "address_1", "address_2", "address_3", "street", "city", "state",
|
|
200
|
+
"postal_code", "country": Address details passed to `
|
|
201
|
+
Address.from_dict`.
|
|
202
|
+
- "latitude", "longitude" (float): Geographic coordinates
|
|
203
|
+
of the brewery.
|
|
204
|
+
- "phone" (str): The brewery's phone number.
|
|
205
|
+
- "website_url" (str): The brewery's website URL.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Brewery: An instance of the `Brewery` class.
|
|
209
|
+
|
|
210
|
+
Raises:
|
|
211
|
+
KeyError: If required fields are missing in the input data.
|
|
212
|
+
TypeError: If the input data is not a dictionary or contains i
|
|
213
|
+
ncorrect types.
|
|
214
|
+
|
|
215
|
+
Example:
|
|
216
|
+
>>> data = {
|
|
217
|
+
... "id": "b54b16e1-ac3b-4bff-a11f-f7ae9ddc27e0",
|
|
218
|
+
... "name": "MadTree Brewing 2.0",
|
|
219
|
+
... "brewery_type": "large",
|
|
220
|
+
... "address_1": "5164 Kennedy Ave",
|
|
221
|
+
... "city": "Cincinnati",
|
|
222
|
+
... "state": "Ohio",
|
|
223
|
+
... "postal_code": "45213",
|
|
224
|
+
... "country": "United States",
|
|
225
|
+
... "latitude": 39.1885752,
|
|
226
|
+
... "longitude": -84.4137736,
|
|
227
|
+
... "phone": "5138368733",
|
|
228
|
+
... "website_url": "http://www.madtreebrewing.com",
|
|
229
|
+
... }
|
|
230
|
+
>>> brewery = Brewery.from_dict(data)
|
|
231
|
+
>>> brewery.phones
|
|
232
|
+
'5138368733'
|
|
233
|
+
"""
|
|
234
|
+
address = Address.from_dict(data)
|
|
235
|
+
return cls(
|
|
236
|
+
id=data["id"],
|
|
237
|
+
name=data["name"],
|
|
238
|
+
brewery_type=data["brewery_type"],
|
|
239
|
+
address=address,
|
|
240
|
+
phone=data.get("phone"),
|
|
241
|
+
website_url=data.get("website_url"),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def to_flat_dict(self) -> dict:
|
|
245
|
+
"""Returns a flattened dictionary from Brewery instance."""
|
|
246
|
+
return {
|
|
247
|
+
"id": self.id,
|
|
248
|
+
"name": self.name,
|
|
249
|
+
"brewery_type": self.brewery_type,
|
|
250
|
+
"phone": self.phone,
|
|
251
|
+
"website_url": self.website_url,
|
|
252
|
+
"address_one": self.address.address_one,
|
|
253
|
+
"address_two": self.address.address_two,
|
|
254
|
+
"address_three": self.address.address_three,
|
|
255
|
+
"postal_code": self.address.postal_code,
|
|
256
|
+
"city": self.address.city,
|
|
257
|
+
"state": self.address.state,
|
|
258
|
+
"country": self.address.country,
|
|
259
|
+
"street": self.address.street,
|
|
260
|
+
"latitude": self.address.coordinate.latitude
|
|
261
|
+
if self.address.coordinate
|
|
262
|
+
else None,
|
|
263
|
+
"longitude": self.address.coordinate.longitude
|
|
264
|
+
if self.address.coordinate
|
|
265
|
+
else None,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class BreweryType(Enum):
|
|
270
|
+
MICRO = "micro"
|
|
271
|
+
NANO = "nano"
|
|
272
|
+
REGIONAL = "regional"
|
|
273
|
+
BREWPUB = "brewpub"
|
|
274
|
+
PLANNING = "planning"
|
|
275
|
+
CONTRACT = "contract"
|
|
276
|
+
PROPRIETOR = "proprietor"
|
|
277
|
+
CLOSED = "closed"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
BREWERY_TYPES = [t.value for t in BreweryType]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@dataclass
|
|
284
|
+
class SearchQuery:
|
|
285
|
+
"""
|
|
286
|
+
Represents a set of search parameters for querying the OD Brewery API
|
|
287
|
+
in a form it excpects.c
|
|
288
|
+
|
|
289
|
+
This class captures user input, validates certain parameters, and provides
|
|
290
|
+
a method to convert the data into a dictionary suitable for use as query
|
|
291
|
+
parameters in API requests.
|
|
292
|
+
|
|
293
|
+
Attributes:
|
|
294
|
+
by_city (str | None): The city to filter the search results by.
|
|
295
|
+
by_country (str | None): The country to filter the search results by.
|
|
296
|
+
by_dist (str | None): A string representing distance in the format
|
|
297
|
+
"latitude,longitude".
|
|
298
|
+
by_name (str | None): The name of the brewery to search for.
|
|
299
|
+
by_state (str | None): The state to filter the search results by.
|
|
300
|
+
postal (str | None): The postal code to filter the search results by.
|
|
301
|
+
type (BreweryType | None): The type of brewery to filter results by.
|
|
302
|
+
sort_order (str | None): The sorting order for the results,
|
|
303
|
+
either 'asc' or 'desc'.
|
|
304
|
+
by_ids (list[str] | None): A list of brewery IDs to filter the search
|
|
305
|
+
results by.
|
|
306
|
+
page (int | None): The page number for paginated results. Defaults to 1.
|
|
307
|
+
per_page (int | None): The number of results per page. Defaults to 50, with
|
|
308
|
+
a maximum value of 200.
|
|
309
|
+
|
|
310
|
+
Methods:
|
|
311
|
+
__post_init__(): Validates the parameters, ensuring sort_order is either
|
|
312
|
+
'asc' or 'desc', page is 1 or greater, and per_page is between 1 and 200.
|
|
313
|
+
to_params() -> dict: Converts the dataclass fields into a dictionary of
|
|
314
|
+
query parameters, excluding any fields with a value of `None`.
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
city: str | None = None
|
|
318
|
+
country: str | None = None
|
|
319
|
+
coord: Coordinate | None = None
|
|
320
|
+
name: str | None = None
|
|
321
|
+
state: str | None = None
|
|
322
|
+
postal: str | None = None
|
|
323
|
+
type: str | None = None
|
|
324
|
+
sort_order: str | None = None
|
|
325
|
+
ids: list[str] | None = None
|
|
326
|
+
page: int | None = 1
|
|
327
|
+
per_page: int | None = 50 # Default 50, max 200
|
|
328
|
+
|
|
329
|
+
def __post_init__(self):
|
|
330
|
+
if self.sort_order is not None and self.sort_order not in ["asc", "desc"]:
|
|
331
|
+
raise ValueError(
|
|
332
|
+
f"Invalid sort_order '{self.sort_order}'. Must be 'asc', 'desc', "
|
|
333
|
+
"or None."
|
|
334
|
+
)
|
|
335
|
+
if (self.page is not None) and (
|
|
336
|
+
not isinstance(self.page, int) or self.page < 1
|
|
337
|
+
):
|
|
338
|
+
raise ValueError(
|
|
339
|
+
f"Invalid page: {self.page}. Must be an integer greater than "
|
|
340
|
+
"or equal to 1."
|
|
341
|
+
)
|
|
342
|
+
if (self.per_page is not None) and (
|
|
343
|
+
not isinstance(self.per_page, int) or not 1 <= self.per_page <= 200
|
|
344
|
+
):
|
|
345
|
+
raise ValueError(
|
|
346
|
+
f"Invalid per_page: {self.per_page}. Must be an integer "
|
|
347
|
+
"from 1 to 200."
|
|
348
|
+
)
|
|
349
|
+
if self.type is not None and self.type not in BREWERY_TYPES:
|
|
350
|
+
raise ValueError(
|
|
351
|
+
f"Invalid value for type: {self.type}. Must be one of "
|
|
352
|
+
f"{', '.join(BREWERY_TYPES)}",
|
|
353
|
+
)
|
|
354
|
+
if self.coord is not None and not isinstance(self.coord, Coordinate):
|
|
355
|
+
raise ValueError(
|
|
356
|
+
f"Invalid value for by_dist: {type(self.coord)}. Must be of "
|
|
357
|
+
"type Coordinate."
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
def to_params(self) -> dict:
|
|
361
|
+
"""
|
|
362
|
+
Converts the SearchQuery instance into a dictionary of query parameters.
|
|
363
|
+
|
|
364
|
+
This method constructs a dictionary by mapping the dataclass attributes
|
|
365
|
+
to their respective API query parameter names. Fields with `None` values
|
|
366
|
+
are excluded from the final dictionary.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
dict: A dictionary of query parameters suitable for use in API requests.
|
|
370
|
+
|
|
371
|
+
Example:
|
|
372
|
+
>>> query = SearchQuery(city="Denver", country="US", page=2)
|
|
373
|
+
>>> query.to_params()
|
|
374
|
+
{'by_city': 'Denver', 'by_country': 'US', 'page': 2}
|
|
375
|
+
"""
|
|
376
|
+
params = {
|
|
377
|
+
"by_city": self.city,
|
|
378
|
+
"by_country": self.country,
|
|
379
|
+
"by_dist": self.coord.to_str()
|
|
380
|
+
if isinstance(self.coord, Coordinate)
|
|
381
|
+
else None,
|
|
382
|
+
"by_ids": ",".join(self.ids) if self.ids else None,
|
|
383
|
+
"by_name": self.name,
|
|
384
|
+
"by_state": self.state,
|
|
385
|
+
"by_postal": self.postal,
|
|
386
|
+
"by_type": self.type,
|
|
387
|
+
"page": self.page,
|
|
388
|
+
"per_page": self.per_page,
|
|
389
|
+
"sort_order": self.sort_order,
|
|
390
|
+
}
|
|
391
|
+
# Remove keys with `None` values
|
|
392
|
+
return {k: v for k, v in params.items() if v is not None}
|
brewcli/utils.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: brewcli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A command-line interface for exploring breweries via the Open Brewery DB API.
|
|
5
|
+
Project-URL: Homepage, https://github.com/tynardone/brewcli
|
|
6
|
+
Project-URL: Repository, https://github.com/tynardone/brewcli
|
|
7
|
+
Author-email: Tyler Nardone <tynardone@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: beer,brewery,cli,openbrewerydb
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Utilities
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: click>=8.1.8
|
|
19
|
+
Requires-Dist: httpx>=0.28.1
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# brewcli
|
|
23
|
+
|
|
24
|
+
> A command-line interface (CLI) for exploring breweries via the [Open Brewery DB API](https://www.openbrewerydb.org/).
|
|
25
|
+
|
|
26
|
+
![Code Style: Ruff][ruff-style]
|
|
27
|
+
![Type Checked: Mypy][mypy-check]
|
|
28
|
+
![Python Versions][python-versions]
|
|
29
|
+
![License][license]
|
|
30
|
+
![Downloads][downloads]
|
|
31
|
+
|
|
32
|
+
`brewcli` is a Python-based CLI tool designed to interact with the [Open Brewery DB API](https://www.openbrewerydb.org/). With `brewcli`, you can fetch random breweries, search breweries by city, state, or type, and more—all from the command line.
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
|
|
36
|
+
- Fetch a list of random breweries.
|
|
37
|
+
- Search breweries by city, country, name, postal code, state, type, or distance from a coordinate.
|
|
38
|
+
- Retrieve detailed information about a specific brewery by its ID.
|
|
39
|
+
- Output data in a user-friendly format.
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
Install `brewcli` using pip:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
pip install brewcli
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Usage example
|
|
50
|
+
|
|
51
|
+
Here are some examples of how to use brewcli:
|
|
52
|
+
|
|
53
|
+
Get a number of random breweries (the count is required):
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
brewcli random 5
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Look up a specific brewery by its ID:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
brewcli by-id b54b16e1-ac3b-4bff-a11f-f7ae9ddc27e0
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Search breweries by city:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
brewcli search --by-city "Cincinnati"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Search breweries by state and type:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
brewcli search --by-state "California" --by-type "micro"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Available `search` filters: `--by-city`, `--by-country`, `--by-dist` (coordinates as
|
|
78
|
+
`'lat,lon'`), `--by-name`, `--by-postal`, `--by-state`, and `--by-type` (one of:
|
|
79
|
+
`micro`, `nano`, `regional`, `brewpub`, `planning`, `contract`, `proprietor`, `closed`).
|
|
80
|
+
|
|
81
|
+
Run `brewcli --help` or `brewcli <command> --help` for full usage details.
|
|
82
|
+
|
|
83
|
+
## Development setup
|
|
84
|
+
|
|
85
|
+
This project uses [uv](https://docs.astral.sh/uv/) for dependency management and is
|
|
86
|
+
pinned to Python 3.12 (see `.python-version`).
|
|
87
|
+
|
|
88
|
+
Clone the repository:
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
git clone https://github.com/tynardone/brewcli.git
|
|
92
|
+
cd brewcli
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Create the virtual environment and install all dependencies (runtime + dev) from the
|
|
96
|
+
lockfile:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
uv sync
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Run the CLI:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
uv run brewcli --help
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Run tests:
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
uv run pytest
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Release History
|
|
115
|
+
|
|
116
|
+
- 0.1.0
|
|
117
|
+
- Work in progress
|
|
118
|
+
|
|
119
|
+
## Meta
|
|
120
|
+
|
|
121
|
+
Tyler Nardone – <tynardone@gmail.com> - [LinkedIn](https://www.linkedin.com/in/tynardone/)
|
|
122
|
+
|
|
123
|
+
Distributed under the MIT license. See ``LICENSE`` for more information.
|
|
124
|
+
|
|
125
|
+
[https://github.com/tynardone/brewcli](https://github.com/tynardone/brewcli)
|
|
126
|
+
|
|
127
|
+
<!-- Markdown link & img dfn's -->
|
|
128
|
+
[python-versions]: https://img.shields.io/pypi/pyversions/brewcli
|
|
129
|
+
[license]: https://img.shields.io/github/license/tynardone/brewcli
|
|
130
|
+
[downloads]: https://img.shields.io/pypi/dm/brewcli
|
|
131
|
+
[ruff-style]:https://img.shields.io/badge/code%20style-ruff-000000?style=flat&logo=python
|
|
132
|
+
[mypy-check]:https://img.shields.io/badge/type%20checked-mypy-blue?style=flat&logo=python
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
brewcli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
brewcli/__main__.py,sha256=8hDtWlaFZK24KhfNq_ZKgtXqYHsDQDetukOCMlsbW0Q,59
|
|
3
|
+
brewcli/brewery.py,sha256=UxVH4KVXl_8y_DuvRcVfkHwi13rwIA1Isr9KHrOiH4o,3923
|
|
4
|
+
brewcli/cli.py,sha256=tKfQ_StGOrNmyeMXnD0wl0on1B6B96DOUS3TgKikrz0,3504
|
|
5
|
+
brewcli/logging.py,sha256=WVyfrUFb76KLNicpLCA4qkcaZs3iio0SAIcCiBoddSA,475
|
|
6
|
+
brewcli/logging_config.json,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
brewcli/models.py,sha256=EtWPo7kntpO8o73us2VN2ut8EHAkq4SwHqa2HOc2HZ8,13925
|
|
8
|
+
brewcli/utils.py,sha256=cskac6dy_UMQ1y6iQVaikNI2C-gqBEXu3Sk7h4zSScw,112
|
|
9
|
+
brewcli-0.1.0.dist-info/METADATA,sha256=1-V1DIahy837hWbbk5j8SFUpaKyV53X2guO7pbKVtL8,3566
|
|
10
|
+
brewcli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
11
|
+
brewcli-0.1.0.dist-info/entry_points.txt,sha256=OI8MmuKfm_uGiK85alZJs7VppTNNIpTNf_lqKDUAbOY,44
|
|
12
|
+
brewcli-0.1.0.dist-info/licenses/LICENSE,sha256=cTJ4K4Xjoc3FPoYKGyhI0HwBtf-ZaKi9noJ4DJIR3kk,1072
|
|
13
|
+
brewcli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Tyler Nardone
|
|
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.
|