Compare commits
14 Commits
v1.1.2
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
| ea6725f833 | |||
| e4e98b4f74 | |||
| fe8b033646 | |||
| 13c67bdad5 | |||
| 1e4b210670 | |||
| 4f3d8d7800 | |||
| 72b225bf10 | |||
| 5c536ccbe5 | |||
| d17b87c310 | |||
| ab997f2e22 | |||
| b52e31a4dc | |||
| 829407bb14 | |||
| cb429dd78e | |||
| b7de6d8119 |
24
.gitignore
vendored
24
.gitignore
vendored
@@ -1,22 +1,2 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
# Pytest coverate
|
||||
.coverage
|
||||
|
||||
# all .env files
|
||||
.env
|
||||
|
||||
# uv lock files
|
||||
uv.lock
|
||||
# composer lock file
|
||||
composer.lock
|
||||
vendor/
|
||||
.venv/
|
||||
**/__pycache__/**
|
||||
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.14
|
||||
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
"python.analysis.autoImportCompletions": true
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
# Guidlines
|
||||
|
||||
Code has to pass flake8 coding guidlines
|
||||
|
||||
http://flake8.pycqa.org/en/latest/
|
||||
[http://flake8.pycqa.org/en/latest/](http://flake8.pycqa.org/en/latest/)
|
||||
|
||||
35
README.md
35
README.md
@@ -4,14 +4,7 @@ Reverse GeoLocate from XMP sidecar files with optional LightRoom DB read
|
||||
|
||||
This script will update any of the Country Code, Country, State, City and Location data that is missing in sidecard files. If a Lightroom DB is set, it will read any data from the database and fill in the fields before it tries to get the location name from google with the Latitude and Longitude found in either the XMP sidecar file or the LR database.
|
||||
|
||||
## Installing and setting up
|
||||
|
||||
The script uses the following external non defauly python libraries
|
||||
|
||||
* xmp toolkit
|
||||
* requests
|
||||
|
||||
install both with the pip3 command
|
||||
## Development Setup
|
||||
|
||||
```sh
|
||||
uv sync
|
||||
@@ -33,10 +26,10 @@ uv run reverse-geolocate [-h] -i
|
||||
[-v] [--debug] [--test]
|
||||
```
|
||||
|
||||
or if outside of the project dir
|
||||
to run it from a differnt path
|
||||
|
||||
```sh
|
||||
uv run --project <path to project> reverse-geolocate
|
||||
uv run --project <path to project> reverse-geolocate <arguments ...>
|
||||
```
|
||||
|
||||
### Arguments
|
||||
@@ -140,17 +133,17 @@ order | type | target set
|
||||
After the script is done the following overview will be printed
|
||||
|
||||
```txt
|
||||
========================================
|
||||
XMP Files found : 57
|
||||
Updated : 3
|
||||
Skipped : 54
|
||||
New GeoLocation from Map : 2
|
||||
GeoLocation from Cache : 1
|
||||
GeoLocation from Fuzzy Cache : 0
|
||||
Failed reverse GeoLocate : 0
|
||||
GeoLocaction from Lightroom : 1
|
||||
No Lightroom data found : 46
|
||||
More than one found in LR : 0
|
||||
============================================
|
||||
XMP Files found : 57
|
||||
Updated : 3
|
||||
Skipped : 54
|
||||
New GeoLocation from Map : 2
|
||||
GeoLocation from Cache : 1
|
||||
GeoLocation from Fuzzy Cache : 0
|
||||
Failed reverse GeoLocate : 0
|
||||
GeoLocaction from Lightroom : 1
|
||||
No Lightroom data found : 46
|
||||
More than one found in Lightroom : 0
|
||||
```
|
||||
|
||||
If there are problems with getting data from the Google Maps API the complete errior sting will be printed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "reverse-geolocate"
|
||||
version = "1.1.2"
|
||||
description = "Reverse Geolocate from Lat/Long information and write location information into sidecard files"
|
||||
version = "0.1.0"
|
||||
description = "Reverse Geolcate script"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
@@ -15,3 +15,62 @@ reverse-geolocate = "reverse_geolocate.reverse_geolocate:main"
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.9.7,<0.10.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
|
||||
# MARK: Python linting
|
||||
[tool.pyright]
|
||||
typeCheckingMode = "strict"
|
||||
reportMissingImports = "information"
|
||||
reportMissingTypeStubs = "information"
|
||||
reportUnknownMemberType = "information"
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
[tool.black]
|
||||
# set 10 short for better formatting
|
||||
line-length = 110
|
||||
# to avoid " ... " " ... " string sets
|
||||
# experimental-string-processing = true
|
||||
preview = true
|
||||
enable-unstable-feature = ["string_processing"]
|
||||
[tool.pylint.format]
|
||||
max-line-length = 120
|
||||
[tool.pylint.miscellaneous]
|
||||
notes = ["FIXME", "TODO"]
|
||||
notes-rgx = '(FIXME|TODO)(\((TTD-|#)\[0-9]+\))'
|
||||
[tool.flake8]
|
||||
max-line-length = 120
|
||||
ignore = [
|
||||
"E741", # ignore ambigious variable name
|
||||
"W504" # Line break occurred after a binary operator [wrong triggered by "or" in if]
|
||||
]
|
||||
[tool.pylint.MASTER]
|
||||
# this is for the tests/etc folders
|
||||
init-hook='import sys; sys.path.append("src/")'
|
||||
|
||||
# MARK: Testing
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = [
|
||||
"tests",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = [
|
||||
"*/tests/*",
|
||||
"*/test_*.py",
|
||||
"*/__init__.py"
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"def __str__",
|
||||
"raise AssertionError",
|
||||
"raise NotImplementedError",
|
||||
"if __name__ == .__main__.:"
|
||||
]
|
||||
exclude_also = [
|
||||
"def __.*__\\(",
|
||||
"def __.*\\(",
|
||||
"def _.*\\(",
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
0
src/reverse_geolocate/utils/__init__.py
Normal file
0
src/reverse_geolocate/utils/__init__.py
Normal file
148
src/reverse_geolocate/utils/long_lat.py
Normal file
148
src/reverse_geolocate/utils/long_lat.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
latitude/longitude functions
|
||||
"""
|
||||
|
||||
import re
|
||||
from math import radians, sin, cos, atan2, sqrt
|
||||
|
||||
|
||||
def convert_lat_long_to_dms(
|
||||
lat_long: float,
|
||||
is_latitude: bool = False,
|
||||
is_longitude: bool = False
|
||||
) -> str:
|
||||
"""
|
||||
convert the LR format of N.N to the Exif GPS format
|
||||
|
||||
Args:
|
||||
lat_long(str): latLong in (-)N.N format
|
||||
is_latitude (bool, optional): flag, else we can't set North/Sout. Defaults to False.
|
||||
is_longitude (bool, optional): flag, else we can't set West/East. Defaults to False.
|
||||
|
||||
Returns:
|
||||
string: Deg,Min.Sec(NESW) format
|
||||
"""
|
||||
# minus part before . and then multiply rest by 60
|
||||
degree = int(abs(lat_long))
|
||||
minutes = round((float(abs(lat_long)) - int(abs(lat_long))) * 60, 10)
|
||||
if is_latitude is True:
|
||||
direction = "S" if int(lat_long) < 0 else "N"
|
||||
elif is_longitude is True:
|
||||
direction = "W" if int(lat_long) < 0 else "E"
|
||||
else:
|
||||
direction = "(INVALID)"
|
||||
return f"{degree},{minutes}{direction}"
|
||||
|
||||
|
||||
def convert_lat_to_dms(lat_long: float) -> str:
|
||||
"""
|
||||
wrapper functions for Long/Lat calls: latitude
|
||||
|
||||
Args:
|
||||
lat_long(str): latLong in (-)N.N format
|
||||
|
||||
Returns:
|
||||
string: Deg,Min.Sec(NESW) format
|
||||
"""
|
||||
return convert_lat_long_to_dms(lat_long, is_latitude=True)
|
||||
|
||||
|
||||
# wrapper for Long/Lat call: longitute
|
||||
def convert_long_to_dms(lat_long: float) -> str:
|
||||
"""
|
||||
wrapper for Long/Lat call: longitute
|
||||
|
||||
Args:
|
||||
lat_long(str): latLong in (-)N.N format
|
||||
|
||||
Returns:
|
||||
string: Deg,Min.Sec(NESW) format
|
||||
"""
|
||||
return convert_lat_long_to_dms(lat_long, is_longitude=True)
|
||||
|
||||
|
||||
def long_lat_reg(longitude: str, latitude: str) -> dict[str, float]:
|
||||
"""
|
||||
converts the XMP/EXIF formatted GPS Long/Lat coordinates
|
||||
from the <Degree>,<Minute.Second><NSEW> to the normal float
|
||||
number used in google/lr internal
|
||||
|
||||
Args:
|
||||
longitude(str): n,n.nNSEW format
|
||||
latitude(str): n,n.nNSEW format
|
||||
|
||||
Returns:
|
||||
dictionary: dict with converted lat/long
|
||||
"""
|
||||
# regex
|
||||
latlong_re = re.compile(r"^(\d+),(\d+\.\d+)([NESW]{1})$")
|
||||
# dict for loop
|
||||
lat_long: dict[str, str | float] = {"longitude": longitude, "latitude": latitude}
|
||||
# for element in lat_long:
|
||||
for index, element in lat_long.items():
|
||||
# match if it is exif GPS format
|
||||
_match = latlong_re.match(element)
|
||||
if _match is not None:
|
||||
# convert from Degree, Min.Sec into float format
|
||||
lat_long[index] = float(_match.group(1)) + (float(_match.group(2)) / 60)
|
||||
# if S or W => inverse to negative
|
||||
if _match.group(3) == "S" or _match.group(3) == "W":
|
||||
lat_long[index] *= -1
|
||||
return lat_long
|
||||
|
||||
|
||||
def convert_dms_to_lat(lat_long):
|
||||
"""
|
||||
rapper calls for DMS to Lat/Long: latitude
|
||||
|
||||
Args:
|
||||
lat_long(str): n,n.nNSEW format
|
||||
|
||||
Returns:
|
||||
dict: dict with converted lat/long
|
||||
"""
|
||||
return long_lat_reg("0,0.0N", lat_long)["latitude"]
|
||||
|
||||
|
||||
def convert_dms_to_long(lat_long):
|
||||
"""
|
||||
wrapper calls for DMS to Lat/Long: longitude
|
||||
|
||||
Args:
|
||||
lat_long(str): n,n.nNSEW format
|
||||
|
||||
Returns:
|
||||
dict: dict with converted lat/long
|
||||
"""
|
||||
return long_lat_reg(lat_long, "0,0.0N")["longitude"]
|
||||
|
||||
|
||||
def get_distance(from_longitude, from_latitude, to_longitude, to_latitude):
|
||||
"""
|
||||
calculates the difference between two coordinates
|
||||
|
||||
Args:
|
||||
from_longitude(str): from longitude
|
||||
from_latitude(str): from latitude
|
||||
to_longitude(str): to longitude
|
||||
to_latitude(str): to latitude
|
||||
|
||||
Returns:
|
||||
float: distance in meters
|
||||
"""
|
||||
# earth radius in meters
|
||||
earth_radius = 6378137.0
|
||||
# convert all from radians with pre convert DMS to long and to float
|
||||
from_longitude = radians(float(convert_dms_to_long(from_longitude)))
|
||||
from_latitude = radians(float(convert_dms_to_lat(from_latitude)))
|
||||
to_longitude = radians(float(convert_dms_to_long(to_longitude)))
|
||||
to_latitude = radians(float(convert_dms_to_lat(to_latitude)))
|
||||
# distance from - to
|
||||
distance_longitude = from_longitude - to_longitude
|
||||
distance_latitude = from_latitude - to_latitude
|
||||
# main distance calculation
|
||||
distance = (
|
||||
sin(distance_latitude / 2) ** 2 + cos(from_latitude) * cos(to_latitude) * sin(distance_longitude / 2) ** 2
|
||||
)
|
||||
distance = 2 * atan2(sqrt(distance), sqrt(1 - distance))
|
||||
return earth_radius * distance
|
||||
248
src/reverse_geolocate/utils/reverse_geolocate.py
Normal file
248
src/reverse_geolocate/utils/reverse_geolocate.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
reverse geolacte functions
|
||||
"""
|
||||
|
||||
import re
|
||||
import requests
|
||||
from reverse_geolocate.utils.long_lat import long_lat_reg
|
||||
from reverse_geolocate.utils.string_helpers import only_latin_chars
|
||||
|
||||
|
||||
def reverse_geolocate(longitude, latitude, map_type, args):
|
||||
"""
|
||||
wrapper to call to either the google or openstreetmap
|
||||
|
||||
Args:
|
||||
longitude (float): latitude
|
||||
latitude (float): longitue
|
||||
map_type(str): map search target (google or openstreetmap)
|
||||
args (_type_): _description_
|
||||
|
||||
Returns:
|
||||
_type_: dict with all data (see below)
|
||||
"""
|
||||
# clean up long/lat
|
||||
# they are stored with N/S/E/W if they come from an XMP
|
||||
# format: Deg,Min.Sec[NSEW]
|
||||
# NOTE: lat is N/S, long is E/W
|
||||
# detect and convert
|
||||
lat_long = long_lat_reg(longitude=longitude, latitude=latitude)
|
||||
# which service to use
|
||||
if map_type == "google":
|
||||
return reverse_geolocate_google(lat_long["longitude"], lat_long["latitude"], args)
|
||||
elif map_type == "openstreetmap":
|
||||
return reverse_geolocate_open_street_map(lat_long["longitude"], lat_long["latitude"], args)
|
||||
else:
|
||||
return {"Country": "", "status": "ERROR", "error": "Map type not valid"}
|
||||
|
||||
|
||||
def reverse_geolocate_init(longitude, latitude):
|
||||
"""
|
||||
inits the dictionary for return, and checks the lat/long on valid
|
||||
returns geolocation dict with status = 'ERROR' if an error occurded
|
||||
|
||||
Args:
|
||||
longitude (float): longitude
|
||||
latitude (float): latitude
|
||||
|
||||
Returns:
|
||||
_type_: empty geolocation dictionary, or error flag if lat/long is not valid
|
||||
"""
|
||||
# basic dict format
|
||||
geolocation = {
|
||||
"CountryCode": "",
|
||||
"Country": "",
|
||||
"State": "",
|
||||
"City": "",
|
||||
"Location": "",
|
||||
# below for error reports
|
||||
"status": "",
|
||||
"error_message": "",
|
||||
}
|
||||
# error if long/lat is not valid
|
||||
latlong_re = re.compile(r"^\d+\.\d+$")
|
||||
if not latlong_re.match(str(longitude)) or not latlong_re.match(str(latitude)):
|
||||
geolocation["status"] = "ERROR"
|
||||
geolocation["error_message"] = f"Latitude {latitude} or Longitude {longitude} are not valid"
|
||||
return geolocation
|
||||
|
||||
|
||||
def reverse_geolocate_open_street_map(longitude, latitude, args):
|
||||
"""
|
||||
OpenStreetMap reverse lookcation lookup
|
||||
|
||||
sample:
|
||||
https://nominatim.openstreetmap.org/reverse.php?format=jsonv2&
|
||||
at=<latitude>&lon=<longitude>&zoom=21&accept-languge=en-US,en&
|
||||
|
||||
Args:
|
||||
longitude (float): longitude
|
||||
latitude (float): latitude
|
||||
args (_type_): _description_
|
||||
|
||||
Returns:
|
||||
dictionary: dict with locaiton, city, state, country, country code
|
||||
if not fillable, entry is empty
|
||||
"""
|
||||
# init
|
||||
geolocation = reverse_geolocate_init(longitude, latitude)
|
||||
if geolocation["status"] == "ERROR":
|
||||
return geolocation
|
||||
# query format
|
||||
query_format = "jsonv2"
|
||||
# language to return (english)
|
||||
language = "en-US,en"
|
||||
# build query
|
||||
base = "https://nominatim.openstreetmap.org/reverse.php?"
|
||||
# parameters
|
||||
payload = {"format": query_format, "lat": latitude, "lon": longitude, "accept-language": language}
|
||||
# if we have an email, add it here
|
||||
if args.email:
|
||||
payload["email"] = args.email
|
||||
url = f"{base}"
|
||||
# timeout in seconds
|
||||
timeout = 60
|
||||
response = requests.get(url, params=payload, timeout=timeout)
|
||||
# debug output
|
||||
if args.debug:
|
||||
print(f"OpenStreetMap search for Lat: {latitude}, Long: {longitude}")
|
||||
if args.debug and args.verbose >= 1:
|
||||
print(f"OpenStreetMap response: {response} => JSON: {response.json()}")
|
||||
# type map
|
||||
# Country to Location and for each in order of priority
|
||||
type_map = {
|
||||
"CountryCode": ["country_code"],
|
||||
"Country": ["country"],
|
||||
"State": ["state"],
|
||||
"City": ["city", "city_district", "state_district"],
|
||||
"Location": ["county", "town", "suburb", "hamlet", "neighbourhood", "road"],
|
||||
}
|
||||
# if not error
|
||||
if "error" not in response.json():
|
||||
# get address block
|
||||
addr = response.json()["address"]
|
||||
# loop for locations
|
||||
for loc_index, sub_index in type_map.items():
|
||||
for index in sub_index:
|
||||
if index in addr and not geolocation[loc_index]:
|
||||
geolocation[loc_index] = addr[index]
|
||||
# for loc_index in type_map:
|
||||
# for index in type_map[loc_index]:
|
||||
# if index in addr and not geolocation[loc_index]:
|
||||
# geolocation[loc_index] = addr[index]
|
||||
else:
|
||||
geolocation["status"] = "ERROR"
|
||||
geolocation["error_message"] = response.json()["error"]
|
||||
print(f"Error in request: {geolocation['error']}")
|
||||
# return
|
||||
return geolocation
|
||||
|
||||
|
||||
def reverse_geolocate_google(longitude, latitude, args):
|
||||
"""
|
||||
Google Maps reverse location lookup
|
||||
|
||||
sample:
|
||||
http://maps.googleapis.com/maps/api/geocode/json?latlng=<latitude>,<longitude>&language=<lang>
|
||||
&sensor=false&key=<api key>
|
||||
|
||||
Args:
|
||||
longitude (float): longitude
|
||||
latitude (float): latitude
|
||||
args (_type_): _description_
|
||||
|
||||
Returns:
|
||||
dictionary: dict with location, city, state, country, country code
|
||||
if not fillable, entry is empty
|
||||
"""
|
||||
# init
|
||||
geolocation = reverse_geolocate_init(longitude, latitude)
|
||||
temp_geolocation = geolocation.copy()
|
||||
if geolocation["status"] == "ERROR":
|
||||
return geolocation
|
||||
# sensor (why?)
|
||||
sensor = "false"
|
||||
# language, so we get ascii en back
|
||||
language = "en"
|
||||
# request to google
|
||||
# if a google api key is used, the request has to be via https
|
||||
protocol = "https://" if args.google_api_key else "http://"
|
||||
base = "maps.googleapis.com/maps/api/geocode/json?"
|
||||
# build the base params
|
||||
payload = {"latlng": f"{latitude},{longitude}", "language": language, "sensor": sensor}
|
||||
# if we have a google api key, add it here
|
||||
if args.google_api_key:
|
||||
payload["key"] = args.google_api_key
|
||||
# build the full url and send it to google
|
||||
url = f"{protocol}{base}"
|
||||
# timeout in seconds
|
||||
timeout = 60
|
||||
response = requests.get(url, params=payload, timeout=timeout)
|
||||
# debug output
|
||||
if args.debug:
|
||||
print(f"Google search for Lat: {latitude}, Long: {longitude} with {response.url}")
|
||||
if args.debug and args.verbose >= 1:
|
||||
print(f"Google response: {response} => JSON: {response.json()}")
|
||||
# type map
|
||||
# For automated return of correct data into set to return
|
||||
type_map = {
|
||||
"CountryCode": ["country"],
|
||||
"Country": ["country"],
|
||||
"State": ["administrative_area_level_1", "administrative_area_level_2"],
|
||||
"City": ["locality", "administrative_area_level_3"],
|
||||
"Location": ["sublocality_level_1", "sublocality_level_2", "route"],
|
||||
}
|
||||
# print("Error: {}".format(response.json()['status']))
|
||||
if response.json()["status"] == "OK":
|
||||
# first entry for type = premise
|
||||
for entry in response.json()["results"]:
|
||||
for sub_entry in entry:
|
||||
if sub_entry == "types" and (
|
||||
"premise" in entry[sub_entry]
|
||||
or "route" in entry[sub_entry]
|
||||
or "street_address" in entry[sub_entry]
|
||||
or "sublocality" in entry[sub_entry]
|
||||
):
|
||||
# print("Entry {}: {}".format(sub_entry, entry[sub_entry]))
|
||||
# print("Address {}".format(entry['address_components']))
|
||||
# type
|
||||
# -> country,
|
||||
# -> administrative_area (1, 2),
|
||||
# -> locality,
|
||||
# -> sublocality (_level_1 or 2 first found, then route)
|
||||
# so we get the data in the correct order
|
||||
# for loc_index in type_map:
|
||||
# for index in type_map[loc_index]:
|
||||
for loc_index, sub_index in type_map.items():
|
||||
for index in sub_index:
|
||||
# this is an array, so we need to loop through each
|
||||
for addr in entry["address_components"]:
|
||||
# in types check that index is in there
|
||||
# and the location is not yet set
|
||||
# also check that entry is in LATIN based
|
||||
# NOTE: fallback if all are non LATIN?
|
||||
if index in addr["types"] and not geolocation[loc_index]:
|
||||
# for country code we need to use short name,
|
||||
# else we use long name
|
||||
if loc_index == "CountryCode":
|
||||
if only_latin_chars(addr["short_name"]):
|
||||
geolocation[loc_index] = addr["short_name"]
|
||||
elif not temp_geolocation[loc_index]:
|
||||
temp_geolocation[loc_index] = addr["short_name"]
|
||||
else:
|
||||
if only_latin_chars(addr["long_name"]):
|
||||
geolocation[loc_index] = addr["long_name"]
|
||||
elif not temp_geolocation[loc_index]:
|
||||
temp_geolocation[loc_index] = addr["long_name"]
|
||||
# check that all in geoloaction are filled and if not fille from temp_geolocation dictionary
|
||||
for loc_index in type_map:
|
||||
if not geolocation[loc_index] and temp_geolocation[loc_index]:
|
||||
geolocation[loc_index] = temp_geolocation[loc_index]
|
||||
# write OK status
|
||||
geolocation["status"] = response.json()["status"]
|
||||
else:
|
||||
geolocation["error_message"] = response.json()["error_message"]
|
||||
geolocation["status"] = response.json()["status"]
|
||||
print(f"Error in request: {geolocation['status']} {geolocation['error_message']}")
|
||||
# return
|
||||
return geolocation
|
||||
111
src/reverse_geolocate/utils/string_helpers.py
Normal file
111
src/reverse_geolocate/utils/string_helpers.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
various string helpers1
|
||||
"""
|
||||
|
||||
import unicodedata
|
||||
|
||||
# this is for looking up if string is non latin letters
|
||||
# this is used by isLatin and onlyLatinChars
|
||||
cache_latin_letters = {}
|
||||
|
||||
|
||||
def shorten_string(string, width, placeholder=".."):
|
||||
"""
|
||||
shortens a string to width and attached placeholder
|
||||
|
||||
Args:
|
||||
string(str): string to shorten
|
||||
width (int): length th shorten to
|
||||
placeholder (str, optional): optional string for removed shortend part. Defaults to '..'.
|
||||
|
||||
Returns:
|
||||
string: shortened string
|
||||
"""
|
||||
# get the length with double byte charactes
|
||||
string_length_cjk = string_len_cjk(str(string))
|
||||
# if double byte width is too big
|
||||
if string_length_cjk > width:
|
||||
# set current length and output string
|
||||
cur_len = 0
|
||||
out_string = ""
|
||||
# loop through each character
|
||||
for char in str(string):
|
||||
# set the current length if we add the character
|
||||
cur_len += 2 if unicodedata.east_asian_width(char) in "WF" else 1
|
||||
# if the new length is smaller than the output length to shorten too add the char
|
||||
if cur_len <= (width - len(placeholder)):
|
||||
out_string += char
|
||||
# return string with new width and placeholder
|
||||
return f"{out_string}{placeholder}"
|
||||
else:
|
||||
return str(string)
|
||||
|
||||
|
||||
def string_len_cjk(string):
|
||||
"""
|
||||
because len on string in python counts characters but we need the width
|
||||
count for formatting, we count two for a double byte characters
|
||||
|
||||
Args:
|
||||
string (string): string to check length
|
||||
|
||||
Returns:
|
||||
int: length including double count for double width characters
|
||||
"""
|
||||
# return string len including double count for double width characters
|
||||
return sum(1 + (unicodedata.east_asian_width(c) in "WF") for c in string)
|
||||
|
||||
|
||||
def is_latin(uchr):
|
||||
"""
|
||||
checks via the unciode class if a character is LATIN char based
|
||||
|
||||
from
|
||||
https://stackoverflow.com/a/3308844/7811993
|
||||
|
||||
Args:
|
||||
uchr (str): _description_
|
||||
|
||||
Returns:
|
||||
str: flagged LATIN or not char
|
||||
"""
|
||||
try:
|
||||
# if we found in the dictionary return
|
||||
return cache_latin_letters[uchr]
|
||||
except KeyError:
|
||||
# find LATIN in uncide type returned and set in dictionary for this character
|
||||
return cache_latin_letters.setdefault(uchr, "LATIN" in unicodedata.name(uchr))
|
||||
|
||||
|
||||
def only_latin_chars(unistr):
|
||||
"""
|
||||
chekcs if a string is based on LATIN chars. No for any CJK, Cyrillic, Hebrew, etc
|
||||
|
||||
from:
|
||||
https://stackoverflow.com/a/3308844/7811993
|
||||
|
||||
Args:
|
||||
unistr (str): string
|
||||
|
||||
Returns:
|
||||
bool: True/False for if string is LATIN char based
|
||||
"""
|
||||
return all(is_latin(uchr) for uchr in unistr if uchr.isalpha())
|
||||
|
||||
|
||||
def format_len(string, length):
|
||||
"""
|
||||
in case of CJK characters we need to adjust the format length dynamically
|
||||
calculate correct length based on string given
|
||||
|
||||
Args:
|
||||
string (str): string
|
||||
length (int): format length
|
||||
|
||||
Returns:
|
||||
int: adjusted format legnth
|
||||
"""
|
||||
# returns length udpated for string with double byte characters
|
||||
# get string length normal, get string length including double byte characters
|
||||
# then subtract that from the original length
|
||||
return length - (string_len_cjk(string) - len(string))
|
||||
106
uv.lock
generated
Normal file
106
uv.lock
generated
Normal file
@@ -0,0 +1,106 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.1.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-xmp-toolkit"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytz" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/39/54/c0b3621568031346ce3306beef18c6cabbd10467cdf17fd257d1a7eb3783/python_xmp_toolkit-2.1.0.tar.gz", hash = "sha256:ca0aa2c60d418dd2558767db59953ab5954fb5b87dc0b50cecd60566b0b4e2da", size = 3570012, upload-time = "2025-11-26T20:34:32.795Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/12/a1e57376289c8a949e079b011c8a1272da12317825a5669fa4488af98260/python_xmp_toolkit-2.1.0-py2.py3-none-any.whl", hash = "sha256:62b51dbbf04009030cb0f0871f1ff7e1e30a2ef8d159daf62c3079290f75a0ed", size = 30257, upload-time = "2025-11-26T20:34:30.644Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytz"
|
||||
version = "2025.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reverse-geolocate"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "python-xmp-toolkit" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "python-xmp-toolkit", specifier = ">=2.1.0" },
|
||||
{ name = "requests", specifier = ">=2.32.5" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user