Skip to content

Commit 125e8b8

Browse files
authored
feat: add reverse geolocation strategy parameter (#1309)
1 parent 5d2de6e commit 125e8b8

17 files changed

+1229
-52
lines changed

functions-python/helpers/locations.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1+
from enum import Enum
12
from typing import Dict, Optional
23
from sqlalchemy.orm import Session
34
import pycountry
45
from shared.database_gen.sqlacodegen_models import Feed, Location
56
import logging
67

78

9+
class ReverseGeocodingStrategy(str, Enum):
10+
"""
11+
Enum for reverse geocoding strategies.
12+
"""
13+
14+
PER_POINT = "per-point"
15+
16+
817
def get_country_code(country_name: str) -> Optional[str]:
918
"""
1019
Get ISO 3166 country code from country name

functions-python/helpers/logger.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ def init_logger():
5151
Initializes the logger with level INFO if not set in the environment.
5252
On cloud environment it will also initialize the GCP logger.
5353
"""
54-
logging.basicConfig(level=get_env_logging_level())
54+
logging_level = get_env_logging_level()
55+
logging.basicConfig(level=logging_level)
56+
logging.info("Logger initialized with level: %s", logging_level)
5557
global _logging_initialized
5658
if not is_local_env() and not _logging_initialized:
5759
# Avoids initializing the logs multiple times due to performance concerns
@@ -81,6 +83,7 @@ def get_logger(name: str, stable_id: str = None):
8183
if stable_id
8284
else logging.getLogger(name)
8385
)
86+
logger.setLevel(level=get_env_logging_level())
8487
if stable_id and not any(
8588
isinstance(log_filter, StableIdFilter) for log_filter in logger.filters
8689
):
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import functools
2+
import time
3+
import tracemalloc
4+
import psutil
5+
import logging
6+
7+
8+
def track_metrics(metrics=("time", "memory", "cpu")):
9+
"""Decorator to track specified metrics (time, memory, cpu) during function execution.
10+
The decorator logs the metrics using the provided logger or a default logger if none is provided.
11+
Args:
12+
metrics (tuple): Metrics to track. Options are "time", "memory", "cpu".
13+
Usage:
14+
@track_metrics(metrics=("time", "memory", "cpu"))
15+
def example_function():
16+
data = [i for i in range(10**6)] # Simulate work
17+
time.sleep(1) # Simulate delay
18+
return sum(data)
19+
"""
20+
21+
def decorator(funct):
22+
@functools.wraps(funct)
23+
def wrapper(*args, **kwargs):
24+
logger = kwargs.get("logger")
25+
if not logger:
26+
# Use a default logger if none is provided
27+
logger = logging.getLogger(funct.__name__)
28+
29+
process = psutil.Process()
30+
tracemalloc.start() if "memory" in metrics else None
31+
start_time = time.time() if "time" in metrics else None
32+
cpu_before = (
33+
process.cpu_percent(interval=None) if "cpu" in metrics else None
34+
)
35+
36+
try:
37+
result = funct(*args, **kwargs)
38+
except Exception as e:
39+
logger.error(f"Function '{funct.__name__}' raised an exception: {e}")
40+
raise
41+
finally:
42+
metrics_message = ""
43+
if "time" in metrics:
44+
duration = time.time() - start_time
45+
metrics_message = f"time: {duration:.2f} seconds"
46+
if "memory" in metrics:
47+
current, peak = tracemalloc.get_traced_memory()
48+
tracemalloc.stop()
49+
if metrics_message:
50+
metrics_message += ", "
51+
metrics_message += f"memory: {current / (1024 ** 2):.2f} MB (peak: {peak / (1024 ** 2):.2f} MB)"
52+
if "cpu" in metrics:
53+
cpu_after = process.cpu_percent(interval=None)
54+
if metrics_message:
55+
metrics_message += ", "
56+
metrics_message += f"cpu: {cpu_after - cpu_before:.2f}%"
57+
if len(metrics_message) > 0:
58+
logger.info(
59+
"Function metrics('%s'): %s", funct.__name__, metrics_message
60+
)
61+
return result
62+
63+
return wrapper
64+
65+
return decorator

functions-python/helpers/tests/test_transform.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def test_to_boolean():
1414
assert to_boolean("0") is False
1515
assert to_boolean("no") is False
1616
assert to_boolean("n") is False
17-
assert to_boolean(1) is False
17+
assert to_boolean(1) is True
1818
assert to_boolean(0) is False
1919
assert to_boolean(None) is False
2020
assert to_boolean([]) is False

functions-python/helpers/transform.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,17 @@
1616
from typing import List, Optional
1717

1818

19-
def to_boolean(value):
19+
def to_boolean(value, default_value: Optional[bool] = False) -> bool:
2020
"""
2121
Convert a value to a boolean.
2222
"""
2323
if isinstance(value, bool):
2424
return value
25+
if isinstance(value, (int, float)):
26+
return value != 0
2527
if isinstance(value, str):
26-
return value.lower() in ["true", "1", "yes", "y"]
27-
return False
28+
return value.strip().lower() in ["true", "1", "yes", "y"]
29+
return default_value
2830

2931

3032
def get_nested_value(
@@ -53,3 +55,23 @@ def get_nested_value(
5355
result = current_data.strip()
5456
return result if result else default_value
5557
return current_data
58+
59+
60+
def to_enum(value, enum_class=None, default_value=None):
61+
"""
62+
Convert a value to an enum member of the specified enum class.
63+
64+
Args:
65+
value: The value to convert.
66+
enum_class: The enum class to convert the value to.
67+
default_value: The default value to return if conversion fails.
68+
69+
Returns:
70+
An enum member if conversion is successful, otherwise the default value.
71+
"""
72+
if enum_class and isinstance(value, enum_class):
73+
return value
74+
try:
75+
return enum_class(str(value))
76+
except (ValueError, TypeError):
77+
return default_value

functions-python/reverse_geolocation/.coveragerc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ omit =
44
*/helpers/*
55
*/database_gen/*
66
*/shared/*
7+
*/scripts/*
78

89
[report]
910
exclude_lines =

functions-python/reverse_geolocation/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ This function performs the core reverse geolocation logic. It processes location
6262
- `vehicle_status_url`: Required if `data_type` is `gbfs` and `station_information_url` and `free_bike_status_url` are omitted. URL of the GBFS `vehicle_status.json` file.
6363
- `free_bike_status_url`: Required if `data_type` is `gbfs` and `station_information_url` and `vehicle_status_url` are omitted. URL of the GBFS `free_bike_status.json` file.
6464
- `data_type`: Optional. Specifies the type of data being processed. Can be `gtfs` or `gbfs`. If not provided, the function will attempt to determine the type based on the URLs provided.
65+
- `strategy`: Optional. Specifies the reverse geolocation strategy to use. Defaults to `per-point`.
66+
- `public`: Optional. Indicates whether the resulting geojson files will be public or private. Defaults to `true`.
6567

6668
### Processing Steps:
6769

functions-python/reverse_geolocation/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ shapely
2929
gtfs-kit
3030
matplotlib
3131
jsonpath_ng
32+
psutil
3233

3334
# Configuration
3435
python-dotenv==1.0.0
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
Faker
22
pytest~=7.4.3
33
urllib3-mock
4-
requests-mock
4+
requests-mock
5+
gcp-storage-emulator
6+
geopandas

functions-python/reverse_geolocation/src/parse_request.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
import requests
88
from jsonpath_ng import parse
99

10+
from shared.helpers.locations import ReverseGeocodingStrategy
11+
from shared.helpers.transform import to_boolean, to_enum
12+
1013

1114
def parse_request_parameters(
1215
request: flask.Request,
@@ -45,7 +48,19 @@ def parse_request_parameters(
4548
raise ValueError(
4649
f"Invalid data_type '{data_type}'. Supported types are 'gtfs' and 'gbfs'."
4750
)
48-
return df, stable_id, dataset_id, data_type, urls
51+
public = True
52+
if "public" in request_json:
53+
public = to_boolean(request_json["public"], default_value=True)
54+
strategy = ReverseGeocodingStrategy.PER_POINT
55+
if "strategy" in request_json:
56+
strategy = to_enum(
57+
value=request_json["strategy"],
58+
default_value=ReverseGeocodingStrategy.PER_POINT,
59+
)
60+
else:
61+
logging.info("No strategy provided, using default")
62+
logging.info("Strategy set to: %s.", strategy)
63+
return df, stable_id, dataset_id, data_type, urls, public, strategy
4964

5065

5166
def parse_request_parameters_gtfs(

0 commit comments

Comments
 (0)