| Total Complexity | 65 |
| Total Lines | 341 |
| Duplicated Lines | 15.84 % |
| Changes | 0 | ||
Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.
Common duplication problems, and corresponding solutions are:
Complex classes like senaite.core.api.geo often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
| 1 | # -*- coding: utf-8 -*- |
||
| 2 | # |
||
| 3 | # This file is part of SENAITE.CORE. |
||
| 4 | # |
||
| 5 | # SENAITE.CORE is free software: you can redistribute it and/or modify it under |
||
| 6 | # the terms of the GNU General Public License as published by the Free Software |
||
| 7 | # Foundation, version 2. |
||
| 8 | # |
||
| 9 | # This program is distributed in the hope that it will be useful, but WITHOUT |
||
| 10 | # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS |
||
| 11 | # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more |
||
| 12 | # details. |
||
| 13 | # |
||
| 14 | # You should have received a copy of the GNU General Public License along with |
||
| 15 | # this program; if not, write to the Free Software Foundation, Inc., 51 |
||
| 16 | # Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. |
||
| 17 | # |
||
| 18 | # Copyright 2018-2024 by it's authors. |
||
| 19 | # Some rights reserved, see README and LICENSE. |
||
| 20 | |||
| 21 | import math |
||
| 22 | import pycountry |
||
| 23 | |||
| 24 | from bika.lims.api import is_floatable |
||
| 25 | from bika.lims.api import to_float |
||
| 26 | from bika.lims.api import to_utf8 |
||
| 27 | from six import string_types |
||
| 28 | |||
| 29 | _marker = object() |
||
| 30 | |||
| 31 | |||
| 32 | def get_countries(): |
||
| 33 | """Return the list of countries sorted by name ascending |
||
| 34 | :return: list of countries sorted by name ascending |
||
| 35 | :rtype: list of Country objects |
||
| 36 | """ |
||
| 37 | countries = pycountry.countries |
||
| 38 | return sorted(list(countries), key=lambda s: s.name) |
||
| 39 | |||
| 40 | |||
| 41 | def get_country(thing, default=_marker): |
||
| 42 | """Returns the country object |
||
| 43 | :param thing: country, subdivision object or search term |
||
| 44 | :type thing: Country/Subdivision/string |
||
| 45 | :returns: the country that matches with the parameter passed in |
||
| 46 | :rtype: pycountry.db.Country |
||
| 47 | """ |
||
| 48 | if is_country(thing): |
||
| 49 | return thing |
||
| 50 | |||
| 51 | if is_subdivision(thing): |
||
| 52 | return get_country(thing.country_code) |
||
| 53 | |||
| 54 | if not isinstance(thing, string_types): |
||
| 55 | if default is _marker: |
||
| 56 | raise TypeError("{} is not supported".format(repr(thing))) |
||
| 57 | return default |
||
| 58 | |||
| 59 | try: |
||
| 60 | return pycountry.countries.lookup(thing) |
||
| 61 | except LookupError as e: |
||
| 62 | if default is _marker: |
||
| 63 | raise ValueError(str(e)) |
||
| 64 | return default |
||
| 65 | |||
| 66 | |||
| 67 | def get_country_code(thing, default=_marker): |
||
| 68 | """Returns the 2-character code (alpha2) of the country |
||
| 69 | :param thing: country, subdivision object or search term |
||
| 70 | :return: the 2-character (alpha2) code of the country |
||
| 71 | :rtype: string |
||
| 72 | """ |
||
| 73 | thing = get_country_or_subdivision(thing, default=default) |
||
| 74 | if is_country(thing): |
||
| 75 | return thing.alpha_2 |
||
| 76 | if is_subdivision(thing): |
||
| 77 | return thing.country_code |
||
| 78 | return default |
||
| 79 | |||
| 80 | |||
| 81 | def get_subdivision(subdivision_or_term, parent=None, default=_marker): |
||
| 82 | """Returns the Subdivision object |
||
| 83 | :param subdivision_or_term: subdivision or search term |
||
| 84 | :param subdivision_or_term: Subdivision/string |
||
| 85 | :param parent: filter by parent subdivision or country |
||
| 86 | :returns: the subdivision that matches with the parameter passed in |
||
| 87 | :rtype: pycountry.db.Subdivision |
||
| 88 | """ |
||
| 89 | if is_subdivision(subdivision_or_term): |
||
| 90 | return subdivision_or_term |
||
| 91 | |||
| 92 | if not isinstance(subdivision_or_term, string_types): |
||
| 93 | if default is _marker: |
||
| 94 | raise TypeError("{} is not supported".format( |
||
| 95 | repr(subdivision_or_term))) |
||
| 96 | return default |
||
| 97 | |||
| 98 | # Search by parent |
||
| 99 | if parent: |
||
| 100 | |||
| 101 | def is_match(subdivision): |
||
| 102 | terms = [subdivision.name, subdivision.code] |
||
| 103 | needle = to_utf8(subdivision_or_term) |
||
| 104 | return needle in map(to_utf8, terms) |
||
| 105 | |||
| 106 | subdivisions = get_subdivisions(parent, default=[]) |
||
| 107 | subdivisions = filter(lambda subdiv: is_match(subdiv), subdivisions) |
||
|
|
|||
| 108 | if len(subdivisions) == 1: |
||
| 109 | return subdivisions[0] |
||
| 110 | elif len(subdivisions) > 1: |
||
| 111 | if default is _marker: |
||
| 112 | raise ValueError("More than one subdivision found") |
||
| 113 | return default |
||
| 114 | else: |
||
| 115 | if default is _marker: |
||
| 116 | raise ValueError("No subdivisions found") |
||
| 117 | return None |
||
| 118 | |||
| 119 | # Search directly by term |
||
| 120 | try: |
||
| 121 | return pycountry.subdivisions.lookup(subdivision_or_term) |
||
| 122 | except LookupError as e: |
||
| 123 | if default is _marker: |
||
| 124 | raise ValueError(str(e)) |
||
| 125 | return default |
||
| 126 | |||
| 127 | |||
| 128 | def is_country(thing): |
||
| 129 | """Returns whether the value passed in is a country object |
||
| 130 | """ |
||
| 131 | if not thing: |
||
| 132 | return False |
||
| 133 | # pycountry generates the classes dynamically, we cannot use isinstance |
||
| 134 | return "Country" in repr(type(thing)) |
||
| 135 | |||
| 136 | |||
| 137 | def is_subdivision(thing): |
||
| 138 | """Returns whether the value passed in is a subdivision object |
||
| 139 | """ |
||
| 140 | if not thing: |
||
| 141 | return False |
||
| 142 | # pycountry generates the classes dynamically, we cannot use isinstance |
||
| 143 | return "Subdivision" in repr(type(thing)) |
||
| 144 | |||
| 145 | |||
| 146 | def get_subdivisions(thing, default=_marker): |
||
| 147 | """Returns the first-level subdivisions of the country or subdivision, |
||
| 148 | sorted by code ascending |
||
| 149 | :param thing: country, subdivision object or search term |
||
| 150 | :return: the list of first-level subdivisions of the subdivision/country |
||
| 151 | :rtype: list of pycountry.db.Subdivision |
||
| 152 | """ |
||
| 153 | try: |
||
| 154 | country_or_subdivision = get_country_or_subdivision(thing) |
||
| 155 | country_code = get_country_code(country_or_subdivision) |
||
| 156 | except (ValueError, TypeError) as e: |
||
| 157 | if default is _marker: |
||
| 158 | raise e |
||
| 159 | return default |
||
| 160 | |||
| 161 | # Extract the subdivisions |
||
| 162 | subdivisions = pycountry.subdivisions.get(country_code=country_code) |
||
| 163 | |||
| 164 | # Bail out those that are not first-level |
||
| 165 | if is_subdivision(country_or_subdivision): |
||
| 166 | code = country_or_subdivision.code |
||
| 167 | subdivisions = filter(lambda sub: sub.parent_code == code, subdivisions) |
||
| 168 | else: |
||
| 169 | subdivisions = filter(lambda sub: sub.parent_code is None, subdivisions) |
||
| 170 | |||
| 171 | # Sort by code |
||
| 172 | return sorted(subdivisions, key=lambda s: s.code) |
||
| 173 | |||
| 174 | |||
| 175 | def get_country_or_subdivision(thing, default=_marker): |
||
| 176 | """Returns the country or subdivision for the thing passed-in |
||
| 177 | :param thing: the thing or search term to look for a country or subdivision |
||
| 178 | :type thing: Country/Subdivision/string |
||
| 179 | :return: the country or subdivision for the given thing |
||
| 180 | """ |
||
| 181 | if is_country(thing): |
||
| 182 | return thing |
||
| 183 | if is_subdivision(thing): |
||
| 184 | return thing |
||
| 185 | |||
| 186 | if not isinstance(thing, string_types): |
||
| 187 | if default is _marker: |
||
| 188 | raise TypeError("{} is not supported".format(repr(thing))) |
||
| 189 | return default |
||
| 190 | |||
| 191 | # Maybe a country |
||
| 192 | country = get_country(thing, default=None) |
||
| 193 | if country: |
||
| 194 | return country |
||
| 195 | |||
| 196 | # Maybe a subdivision |
||
| 197 | subdivision = get_subdivision(thing, default=None) |
||
| 198 | if subdivision: |
||
| 199 | return subdivision |
||
| 200 | |||
| 201 | if default is _marker: |
||
| 202 | raise ValueError("Could not find a record for '{}'".format( |
||
| 203 | thing.lower())) |
||
| 204 | return default |
||
| 205 | |||
| 206 | |||
| 207 | def to_dms(degrees, precision=4, default=_marker): |
||
| 208 | """Converts a geographical coordinate in decimal degrees to a dict with |
||
| 209 | degrees, minutes and seconds as the keys |
||
| 210 | |||
| 211 | :param degrees: coordinate in decimal degrees |
||
| 212 | :type degrees: string,int,float |
||
| 213 | :param precision: number of decimals for seconds |
||
| 214 | :type precision: int |
||
| 215 | :return: a dict with degrees, minutes and seconds as keys |
||
| 216 | """ |
||
| 217 | if not is_floatable(degrees): |
||
| 218 | if default is _marker: |
||
| 219 | raise ValueError("Expected decimal degrees to be a floatable, but " |
||
| 220 | "got %r" % degrees) |
||
| 221 | return default |
||
| 222 | |||
| 223 | # calculate the DMS |
||
| 224 | decimal_degrees = to_float(degrees) |
||
| 225 | degrees = math.trunc(decimal_degrees) |
||
| 226 | minutes = math.trunc((decimal_degrees - degrees) * 60) |
||
| 227 | seconds = decimal_degrees * 3600 % 60 |
||
| 228 | |||
| 229 | # check precision type |
||
| 230 | if not isinstance(precision, int): |
||
| 231 | raise TypeError("Expected precision to be an `int`, but got %r" |
||
| 232 | % type(precision)) |
||
| 233 | |||
| 234 | # apply the precision to seconds |
||
| 235 | template = "{:.%df}" % precision |
||
| 236 | seconds = template.format(seconds) |
||
| 237 | |||
| 238 | return { |
||
| 239 | "degrees": degrees, |
||
| 240 | "minutes": minutes, |
||
| 241 | "seconds": to_float(seconds), |
||
| 242 | } |
||
| 243 | |||
| 244 | |||
| 245 | View Code Duplication | def to_latitude_dms(degrees, precision=4, default=_marker): |
|
| 246 | """Converts a geographical latitude in decimal degrees to a dict with |
||
| 247 | degrees, minutes, seconds and bearing as the keys |
||
| 248 | |||
| 249 | :param degrees: latitude in decimal degrees |
||
| 250 | :type degrees: string,int,float |
||
| 251 | :param precision: number of decimals for seconds |
||
| 252 | :type precision: int |
||
| 253 | :return: a dict with degrees, minutes, seconds and bearing as keys |
||
| 254 | """ |
||
| 255 | if not is_floatable(degrees): |
||
| 256 | if default is _marker: |
||
| 257 | raise ValueError("Expected decimal degrees to be a floatable, but " |
||
| 258 | "got %r" % degrees) |
||
| 259 | return default |
||
| 260 | |||
| 261 | # check latitude is in range |
||
| 262 | latitude = to_float(degrees) |
||
| 263 | if abs(latitude) > 90: |
||
| 264 | if default is _marker: |
||
| 265 | raise ValueError("Latitude must be within -90 and 90 degrees") |
||
| 266 | return default |
||
| 267 | |||
| 268 | # calculate the DMS |
||
| 269 | dms = to_dms(abs(latitude), precision=precision) |
||
| 270 | dms["bearing"] = "N" if latitude >= 0 else "S" |
||
| 271 | return dms |
||
| 272 | |||
| 273 | |||
| 274 | View Code Duplication | def to_longitude_dms(degrees, precision=4, default=_marker): |
|
| 275 | """Converts a geographical longitude in decimal degrees to a dict with |
||
| 276 | degrees, minutes, seconds and bearing as the keys |
||
| 277 | |||
| 278 | :param degrees: longitude in decimal degrees |
||
| 279 | :type degrees: string,int,float |
||
| 280 | :param precision: number of decimals for seconds |
||
| 281 | :type precision: int |
||
| 282 | :return: a dict with degrees, minutes, seconds and bearing as keys |
||
| 283 | """ |
||
| 284 | if not is_floatable(degrees): |
||
| 285 | if default is _marker: |
||
| 286 | raise ValueError("Expected decimal degrees to be a floatable, but " |
||
| 287 | "got %r" % degrees) |
||
| 288 | return default |
||
| 289 | |||
| 290 | # check longitude is in range |
||
| 291 | longitude = to_float(degrees) |
||
| 292 | if abs(longitude) > 180: |
||
| 293 | if default is _marker: |
||
| 294 | raise ValueError("Longitude must be within -180 and 180 degrees") |
||
| 295 | return default |
||
| 296 | |||
| 297 | # calculate the DMS |
||
| 298 | dms = to_dms(abs(longitude), precision=precision) |
||
| 299 | dms["bearing"] = "E" if longitude >= 0 else "W" |
||
| 300 | return dms |
||
| 301 | |||
| 302 | |||
| 303 | def to_decimal_degrees(dms, precision=7, default=_marker): |
||
| 304 | """Converts a geographical coordinate in DMS format to decimal degrees |
||
| 305 | |||
| 306 | :param dms: coordinate in DMS |
||
| 307 | :type dms: dict |
||
| 308 | :param precision: number of decimals for decimal degrees |
||
| 309 | :type precision: int |
||
| 310 | :return: a float representing a geographical coordinate in decimal degrees |
||
| 311 | """ |
||
| 312 | if not isinstance(dms, dict): |
||
| 313 | if default is _marker: |
||
| 314 | raise TypeError("Expected dms to be a dict, but got %r" % dms) |
||
| 315 | return default |
||
| 316 | |||
| 317 | # get the degrees, minutes and seconds |
||
| 318 | degrees = to_float(dms.get("degrees"), default=0) |
||
| 319 | minutes = to_float(dms.get("minutes"), default=0) |
||
| 320 | seconds = to_float(dms.get("seconds"), default=0) |
||
| 321 | |||
| 322 | # calculate the decimal degrees |
||
| 323 | decimal_degrees = abs(degrees + (minutes / 60) + (seconds / 3600)) |
||
| 324 | |||
| 325 | # Use +/- to express N/S, W/E |
||
| 326 | bearing = dms.get("bearing") |
||
| 327 | if bearing and bearing in "SW": |
||
| 328 | decimal_degrees = -decimal_degrees |
||
| 329 | |||
| 330 | # check precision type |
||
| 331 | if not isinstance(precision, int): |
||
| 332 | raise TypeError("Expected precision to be an `int`, but got %r" |
||
| 333 | % type(precision)) |
||
| 334 | |||
| 335 | # apply the precision |
||
| 336 | template = "{:.%df}" % precision |
||
| 337 | decimal_degrees = template.format(decimal_degrees) |
||
| 338 | |||
| 339 | # return the float value |
||
| 340 | return to_float(decimal_degrees) |
||
| 341 |