followthemoney 4.0.3__py3-none-any.whl → 4.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.
@@ -9,7 +9,7 @@ from followthemoney.statement import Statement, StatementEntity, SE
9
9
  from followthemoney.dataset import Dataset, DefaultDataset, DS
10
10
  from followthemoney.util import set_model_locale
11
11
 
12
- __version__ = "4.0.3"
12
+ __version__ = "4.1.0"
13
13
 
14
14
  # Data model singleton
15
15
  model = Model.instance()
followthemoney/compare.py CHANGED
@@ -1,7 +1,8 @@
1
1
  import math
2
2
  from itertools import islice, product
3
3
  from typing import Dict, Generator, Iterable, List, Optional
4
- from normality import normalize
4
+ from normality import ascii_text
5
+ from rigour.text.scripts import can_latinize
5
6
  from rigour.names import tokenize_name, remove_person_prefixes
6
7
  from rigour.names import replace_org_types_compare
7
8
  from followthemoney.exc import InvalidData
@@ -91,7 +92,7 @@ def _normalize_names(
91
92
  can_person = schema.is_a("LegalEntity") and not schema.is_a("Organization")
92
93
  can_org = schema.is_a("LegalEntity") and not schema.is_a("Person")
93
94
  for name in names:
94
- plain = normalize(name, ascii=True)
95
+ plain = name.lower().strip()
95
96
  if plain is not None and plain not in seen:
96
97
  seen.add(plain)
97
98
  yield plain
@@ -102,6 +103,9 @@ def _normalize_names(
102
103
  if can_org:
103
104
  name = replace_org_types_compare(name)
104
105
  tokens = tokenize_name(name.lower())
106
+ for token in tokens:
107
+ if can_latinize(token):
108
+ token = ascii_text(token) or token
105
109
  fp = " ".join(sorted(tokens))
106
110
  if fp is not None and len(fp) > 6 and fp not in seen:
107
111
  seen.add(fp)
@@ -1,5 +1,5 @@
1
1
  from datetime import datetime
2
- from normality import slugify
2
+ from normality import slugify_text
3
3
  from typing import Annotated, Any
4
4
  from rigour.time import datetime_iso
5
5
  from pydantic import AfterValidator, BeforeValidator, HttpUrl, PlainSerializer
@@ -11,7 +11,7 @@ def dataset_name_check(value: str) -> str:
11
11
  """Check that the given value is a valid dataset name. This doesn't convert
12
12
  or clean invalid names, but raises an error if they are not compliant to
13
13
  force the user to fix an invalid name"""
14
- if slugify(value, sep="_") != value:
14
+ if slugify_text(value, sep="_") != value:
15
15
  raise ValueError("Invalid %s: %r" % ("dataset name", value))
16
16
  return value
17
17
 
@@ -26,9 +26,9 @@ the server without compromising isolation.
26
26
  import hmac
27
27
  from typing import Any, Optional, Tuple, Union
28
28
 
29
- from followthemoney.types import registry
30
29
  from followthemoney.proxy import E
31
- from followthemoney.util import key_bytes, get_entity_id
30
+ from followthemoney.types import registry
31
+ from followthemoney.util import get_entity_id, key_bytes
32
32
 
33
33
 
34
34
  class Namespace(object):
@@ -69,9 +69,11 @@ class Namespace(object):
69
69
  digest.update(key_bytes(entity_id))
70
70
  return digest.hexdigest()
71
71
 
72
- def sign(self, entity_id: str) -> Optional[str]:
72
+ def sign(self, entity_id: Optional[str]) -> Optional[str]:
73
73
  """Apply a namespace signature to an entity ID, removing any
74
74
  previous namespace marker."""
75
+ if entity_id is None:
76
+ return None
75
77
  parsed_id, _ = self.parse(entity_id)
76
78
  if not len(self.bname):
77
79
  return parsed_id
followthemoney/proxy.py CHANGED
@@ -57,7 +57,7 @@ class EntityProxy(object):
57
57
  #: A unique identifier for this entity, usually a hashed natural key,
58
58
  #: a UUID, or a very simple slug. Can be signed using a
59
59
  #: :class:`~followthemoney.namespace.Namespace`.
60
- self.id = str(data["id"]) if "id" in data else None
60
+ self.id = str(data["id"]) if data.get("id") else None
61
61
  if not cleaned:
62
62
  self.id = sanitize_text(self.id)
63
63
 
@@ -1,7 +1,6 @@
1
1
  import re
2
2
  from typing import Optional, TYPE_CHECKING
3
- from normality import slugify
4
- from normality.cleaning import collapse_spaces
3
+ from normality import slugify_text, collapse_spaces
5
4
  from rigour.addresses import normalize_address
6
5
  from rigour.text.distance import levenshtein_similarity
7
6
 
@@ -39,7 +38,7 @@ class AddressType(PropertyType):
39
38
  address = self.LINE_BREAKS.sub(", ", text)
40
39
  address = self.COMMATA.sub(", ", address)
41
40
  collapsed = collapse_spaces(address)
42
- if collapsed is None or not len(collapsed):
41
+ if collapsed is None:
43
42
  return None
44
43
  return collapsed
45
44
 
@@ -54,7 +53,10 @@ class AddressType(PropertyType):
54
53
  return dampen(10, 60, value)
55
54
 
56
55
  def node_id(self, value: str) -> Optional[str]:
57
- slug = slugify(normalize_address(value))
56
+ normalized = normalize_address(value)
57
+ if normalized is None:
58
+ return None
59
+ slug = slugify_text(normalized)
58
60
  if slug is None:
59
61
  return None
60
- return f"addr:{value}"
62
+ return f"addr:{slug}"
@@ -204,9 +204,10 @@ class PropertyType(object):
204
204
  return data
205
205
 
206
206
  def __eq__(self, other: Any) -> bool:
207
- if not isinstance(other, PropertyType):
207
+ try:
208
+ return self.name == other.name # type: ignore
209
+ except AttributeError:
208
210
  return False
209
- return self.name == other.name
210
211
 
211
212
  def __hash__(self) -> int:
212
213
  return hash(self.name)
@@ -1,11 +1,14 @@
1
1
  import re
2
2
  import logging
3
3
  from typing import Optional, TYPE_CHECKING
4
- from urllib.parse import urlparse
5
- from normality.cleaning import strip_quotes
4
+ from rigour.env import ENCODING
6
5
 
7
6
  from followthemoney.types.common import PropertyType
8
- from followthemoney.util import sanitize_text, defer as _
7
+ from followthemoney.util import defer as _
8
+
9
+
10
+ # Regex to filter out invalid emails from a CSV file:
11
+ # csvgrep -c value -r '^(?![a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$)' contrib/statements_emails.csv > contrib/test_invalid_emails.csv
9
12
 
10
13
  log = logging.getLogger(__name__)
11
14
 
@@ -17,8 +20,9 @@ class EmailType(PropertyType):
17
20
  """Internet mail address (e.g. user@example.com). These are notoriously hard
18
21
  to validate, but we use an irresponsibly simple rule and hope for the best."""
19
22
 
20
- REGEX_RAW = r"^[^@\s]+@[^@\s]+\.\w+$"
21
- REGEX = re.compile(REGEX_RAW)
23
+ DOMAIN_RE = re.compile(r"^(?!-)(?:[a-z0-9-]{1,63}(?<!-)\.)+[a-z0-9-]{2,}$", re.U)
24
+ LOCAL_RE = re.compile(r"^[^<>()\[\]\,;:\?\s@\"]{1,64}$", re.U)
25
+
22
26
  name = "email"
23
27
  group = "emails"
24
28
  label = _("E-Mail Address")
@@ -35,18 +39,29 @@ class EmailType(PropertyType):
35
39
  # except:
36
40
  # return False
37
41
 
42
+ def clean_domain_part(self, domain: str) -> Optional[str]:
43
+ """Clean and normalize the domain part of the email."""
44
+ domain = domain.rstrip(".").lower()
45
+ try:
46
+ # Convert domain to IDNA encoding if it contains non-ASCII characters. This should
47
+ # be idempotent for domains that are already IDNA-encoded.
48
+ domain = domain.encode("idna").decode(ENCODING)
49
+
50
+ # Check if the domain matches the regex pattern, which requires labels to be
51
+ # alphanumeric and hyphenated, and the TLD to be at least two characters long.
52
+ if self.DOMAIN_RE.match(domain) is None:
53
+ return None
54
+
55
+ domain = domain.encode(ENCODING).decode("idna")
56
+ return domain
57
+ except UnicodeError:
58
+ return None
59
+
38
60
  def validate(
39
61
  self, value: str, fuzzy: bool = False, format: Optional[str] = None
40
62
  ) -> bool:
41
63
  """Check to see if this is a valid email address."""
42
- # TODO: adopt email.utils.parseaddr
43
- email = sanitize_text(value)
44
- if email is None or not self.REGEX.match(email):
45
- return False
46
- _, domain = email.rsplit("@", 1)
47
- if len(domain) < 4 or "." not in domain:
48
- return False
49
- return True
64
+ return self.clean_text(value, fuzzy=fuzzy, format=format) is not None
50
65
 
51
66
  def clean_text(
52
67
  self,
@@ -59,23 +74,24 @@ class EmailType(PropertyType):
59
74
 
60
75
  Returns None if this is not an email address.
61
76
  """
62
- email = strip_quotes(text)
63
- if email is None or not self.REGEX.match(email):
64
- return None
65
- mailbox, domain = email.rsplit("@", 1)
66
77
  # TODO: https://pypi.python.org/pypi/publicsuffix/
67
- # handle URLs by extracting the domain name
68
- domain = urlparse(domain).hostname or domain
69
- domain = domain.lower()
70
- domain = domain.rstrip(".")
71
- # handle unicode
78
+ # handle URLs by extracting the domain names
79
+ # or TODO: adopt email.utils.parseaddr
80
+
81
+ # Remove mailto: prefix if present
82
+ email = text.strip()
83
+ if email.startswith("mailto:"):
84
+ email = email[7:]
85
+
72
86
  try:
73
- domain = domain.encode("idna").decode("ascii")
74
- except UnicodeError:
75
- return None
76
- if domain is not None and mailbox is not None:
77
- return "@".join((mailbox, domain))
78
- return None
87
+ local, domain = email.rsplit("@", 1)
88
+ """Clean and validate the local part of the email."""
89
+ if self.LOCAL_RE.match(local) is None:
90
+ return None
79
91
 
80
- # def country_hint(self, value)
81
- # TODO: do we want to use TLDs as country evidence?
92
+ domain_clean = self.clean_domain_part(domain)
93
+ if domain_clean is None:
94
+ return None
95
+ return f"{local}@{domain_clean}"
96
+ except ValueError:
97
+ return None
@@ -1,5 +1,5 @@
1
1
  from typing import TYPE_CHECKING, Optional, Sequence
2
- from normality import slugify
2
+ from normality import slugify_text
3
3
  from normality.cleaning import collapse_spaces, strip_quotes
4
4
  from rigour.env import MAX_NAME_LENGTH
5
5
  from rigour.names import pick_name, tokenize_name
@@ -38,6 +38,8 @@ class NameType(PropertyType):
38
38
  ) -> Optional[str]:
39
39
  """Basic clean-up."""
40
40
  name = strip_quotes(text)
41
+ if name is None:
42
+ return None
41
43
  return collapse_spaces(name)
42
44
 
43
45
  def pick(self, values: Sequence[str]) -> Optional[str]:
@@ -61,7 +63,7 @@ class NameType(PropertyType):
61
63
  )
62
64
 
63
65
  def node_id(self, value: str) -> Optional[str]:
64
- slug = slugify(value)
66
+ slug = slugify_text(value)
65
67
  if slug is None:
66
68
  return None
67
69
  return f"name:{slug}"
followthemoney/util.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import os
2
2
  import sys
3
3
  import logging
4
+ import unicodedata
4
5
  from hashlib import sha1
5
6
  from babel import Locale
6
7
  from gettext import translation
@@ -8,7 +9,6 @@ from gettext import translation
8
9
  from threading import local
9
10
  from typing import cast, Dict, Any, List, Optional, TypeVar, Union
10
11
  from normality import stringify
11
- from normality.cleaning import compose_nfc
12
12
  from normality.cleaning import remove_unsafe_chars
13
13
  from normality.encoding import DEFAULT_ENCODING
14
14
  from banal import is_mapping, unique_list, ensure_list
@@ -69,9 +69,7 @@ def sanitize_text(value: Any, encoding: str = DEFAULT_ENCODING) -> Optional[str]
69
69
  if text is None:
70
70
  return None
71
71
  try:
72
- text = compose_nfc(text)
73
- if text is None:
74
- return None
72
+ text = unicodedata.normalize("NFC", text)
75
73
  except (SystemError, Exception) as ex:
76
74
  log.warning("Cannot NFC text: %s", ex)
77
75
  return None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: followthemoney
3
- Version: 4.0.3
3
+ Version: 4.1.0
4
4
  Summary: A data model for anti corruption data modeling and analysis.
5
5
  Project-URL: Documentation, https://followthemoney.tech/
6
6
  Project-URL: Repository, https://github.com/opensanctions/followthemoney.git
@@ -41,7 +41,7 @@ Requires-Dist: banal<1.1.0,>=1.0.6
41
41
  Requires-Dist: click<9.0.0,>=8.0
42
42
  Requires-Dist: countrynames<2.0.0,>=1.13.0
43
43
  Requires-Dist: networkx<3.5,>=2.5
44
- Requires-Dist: normality<3.0.0,>=2.6.1
44
+ Requires-Dist: normality<4.0.0,>=3.0.0
45
45
  Requires-Dist: openpyxl<4.0.0,>=3.0.5
46
46
  Requires-Dist: orjson<4.0,>=3.10.18
47
47
  Requires-Dist: phonenumbers<10.0.0,>=8.12.22
@@ -51,7 +51,7 @@ Requires-Dist: pytz>=2021.1
51
51
  Requires-Dist: pyyaml<7.0.0,>=5.0.0
52
52
  Requires-Dist: rdflib<7.2.0,>=6.2.0
53
53
  Requires-Dist: requests<3.0.0,>=2.21.0
54
- Requires-Dist: rigour<2.0.0,>=1.0.0
54
+ Requires-Dist: rigour<2.0.0,>=1.1.0
55
55
  Requires-Dist: sqlalchemy[mypy]<3.0.0,>=2.0.0
56
56
  Provides-Extra: dev
57
57
  Requires-Dist: build; extra == 'dev'
@@ -1,5 +1,5 @@
1
- followthemoney/__init__.py,sha256=GmOdQXf1GougYiuFmDULXblg9PKnVRmTQkxLPGc1LgI,856
2
- followthemoney/compare.py,sha256=rtITMzJOXLDOSj7yKPfOxFaknIu6kRpiLDIM22zakpI,5619
1
+ followthemoney/__init__.py,sha256=XG3sMmmgBvV0kMOTtFP0JSqRQnlTSqtDEwtQ4MFxkSs,856
2
+ followthemoney/compare.py,sha256=bZlnj2VMoe67q4Lyq_VwS1a-EJnEK1kC8prbs8jyL9E,5774
3
3
  followthemoney/entity.py,sha256=9wLKE3iFapxRQWOs_OAMzK3wtklf2HXaHaMYydIInWE,3045
4
4
  followthemoney/exc.py,sha256=GyMgwY4QVm87hLevDfV7gM1MJsDqfNCi_UQw7F_A8X8,858
5
5
  followthemoney/graph.py,sha256=7X1CGHGvmktS2LSZqld2iXWzG7B831eCNYyBqamqEJ8,10921
@@ -7,13 +7,13 @@ followthemoney/helpers.py,sha256=Btb6BlHg_c-qCXZo-NP_LURKG-qu-QD3Fj1ev_c7Xic,795
7
7
  followthemoney/messages.py,sha256=zUEa9CFecU8nRafIzhN6TKCh1kEihiIyIS1qr8PxY4g,806
8
8
  followthemoney/model.py,sha256=bWFVNa-DhYzc8BdSXBZdG2ev6Nh9uHx6i4tin8DvEEU,7374
9
9
  followthemoney/names.py,sha256=LODQqExKEHdH4z6Mmbhlm0KeKRzGcptaSWzYXZ7lONI,1120
10
- followthemoney/namespace.py,sha256=cp7X8aGaZ8HHf7SOfHr2vJHPI2todz2DoyLdiZLNMyg,4472
10
+ followthemoney/namespace.py,sha256=utggu9IGA8bhgEYom3OUB1KxkAJR_TrMNbY5MUF_db8,4536
11
11
  followthemoney/ontology.py,sha256=WWY_PYQGl5Ket4zZBuZglzQxD2Bh9UqHok6GJNNX7GA,3001
12
12
  followthemoney/property.py,sha256=RDTzTXJeeLFLptQL1_gr1S1T-vdDe-8MGMwsRaGQh0I,7665
13
- followthemoney/proxy.py,sha256=KhByvSQk1BrZxTinKjjUsbEuD96RveQQ4LRzQIz6pUA,19617
13
+ followthemoney/proxy.py,sha256=LD4K1oPABXMX212UZxwLu7XOHRDyVBwTlqudTUsUZRQ,19619
14
14
  followthemoney/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
15
  followthemoney/schema.py,sha256=WYnPE4Lego0pJHlojECEv0aO9Miw_YIvEb35HoDo4Zk,18087
16
- followthemoney/util.py,sha256=DhhcRilSetZpzvCew56AE6zNwenW5a4Y-KtmKM43rjc,4447
16
+ followthemoney/util.py,sha256=QeNZI0rJPI2KcK2JQ-ka3rk_IobMkFSVN0jk-JJhAWI,4391
17
17
  followthemoney/value.py,sha256=BJ4Sj5Tg2kMrslR6FjQUr96d8Kt75U7ny9NgzVGT0ZE,2335
18
18
  followthemoney/cli/__init__.py,sha256=0mmz84uhXRp2qUn3syKnDXofU3MMAAe291s7htqX0Bg,187
19
19
  followthemoney/cli/aggregate.py,sha256=xQTFpU3cVVj7fplpX4OJVrRlTVpn6b9kBr_Vb87pKfg,2164
@@ -29,7 +29,7 @@ followthemoney/dataset/coverage.py,sha256=rBnKs7VngCtIuaDqrF5D0ygCHg8NAMkYbmtl73
29
29
  followthemoney/dataset/dataset.py,sha256=wWUzWsdzDW9qXLy8lS6Bpy08WMcaNU30oiMXU8jfo14,4724
30
30
  followthemoney/dataset/publisher.py,sha256=nexZe9XexV8WI5Id999vf5OH_DPUmiKQ_GT3c59eF44,893
31
31
  followthemoney/dataset/resource.py,sha256=S_-tNjMwHQ8LcSOsZO_xhXD-vLK90wyxtIRBbyCJ0Xo,1164
32
- followthemoney/dataset/util.py,sha256=ajUIBRF64dizdgy9LAp2abvFXRFOWCqQX9sDbToWFYo,1607
32
+ followthemoney/dataset/util.py,sha256=mfVTXdbNnWly6cXo4SjNzHuJK1c1uNBwULYOVg1gK5I,1617
33
33
  followthemoney/export/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
34
  followthemoney/export/common.py,sha256=5b-Qlu3MaA0kSzzMAP93FAWncpgiioENnCnHikWYxhs,1021
35
35
  followthemoney/export/csv.py,sha256=reWq1jYIv7sY2PEI4JwIxahYNNqnSiPfMCS3kQX4RZ8,2652
@@ -141,12 +141,12 @@ followthemoney/translations/ru/LC_MESSAGES/followthemoney.po,sha256=7SQWytOTvoAQ
141
141
  followthemoney/translations/tr/LC_MESSAGES/followthemoney.mo,sha256=SC84e_ZF_oFJG1NKdyZY_W6Kb6POORZB6wdeAcEWmnE,487
142
142
  followthemoney/translations/tr/LC_MESSAGES/followthemoney.po,sha256=AZC3marhtVVq8Ck1FOgnt4sbDMz548nX48O9GDwImbQ,89826
143
143
  followthemoney/types/__init__.py,sha256=rWwQeiuMh2BNIuvhpMfJ4bPADDvt9Axu1eedvNFi0qY,3350
144
- followthemoney/types/address.py,sha256=11k7kxHAoY46-ypkOQELFCJjrk7yssTOs55vdRWQJBY,2089
144
+ followthemoney/types/address.py,sha256=8qapsxfAPFml9MyAcRxN5m_YkmkZYUUJyBXAiAAYUoE,2133
145
145
  followthemoney/types/checksum.py,sha256=zZrU8WX4CY3Vta_vOyfgDNzIwbmtje7AaDv3O1fBMnk,823
146
- followthemoney/types/common.py,sha256=d9t4BMqcjoMqqKu7UWVF6WKckV0FnRsKQ1LA3OgEq0c,10065
146
+ followthemoney/types/common.py,sha256=4ks7zPT8rknrGSd4JFc1zRkS-TL4SX-25_ZbjcVDos0,10081
147
147
  followthemoney/types/country.py,sha256=mUCjwhUbA5Ef5HYuKb1KbH4aZ3MxaNwE1p77uOZMuG0,1745
148
148
  followthemoney/types/date.py,sha256=PjcaEyW6CBzf0-gHWKUsKjWIaD3AVBEl0zLSRQOVXxc,3105
149
- followthemoney/types/email.py,sha256=zuX7TthKGtkUwN_kEMMip17iMYQDJc5kQUP8j4N6R-A,2598
149
+ followthemoney/types/email.py,sha256=L3RTYrMABlNQF7hCynXGfzoj6YNEHW5JAY_BwuhoZdA,3375
150
150
  followthemoney/types/entity.py,sha256=oDxVEhuxyU1ScpOpebPpUm3o0I9j_p7Qrq-t5yNpluQ,2338
151
151
  followthemoney/types/gender.py,sha256=fi9iKLbjAUxDCLBtU1MxWidxv7KgCY2eH5746FYlEGk,1725
152
152
  followthemoney/types/identifier.py,sha256=hzD188FtwG0w3TcmbnDwnUMc8MZVcWgQJKGAvrwygc4,2296
@@ -154,14 +154,14 @@ followthemoney/types/ip.py,sha256=mMFTODFiXAJROCUYJvoLAShyIiTIWVmMBh5zT_GquYM,13
154
154
  followthemoney/types/json.py,sha256=V3qJD5RxJykNX51u3w1Nx9xqoNBnkulhzkJI9XMYKFo,1690
155
155
  followthemoney/types/language.py,sha256=SXgRRH-DyPmyyrqYurSyMiG6WHB8a0Gw81XxroEGD-c,2747
156
156
  followthemoney/types/mimetype.py,sha256=NdpqVLx3Bre_myYvnbjmdd5wZBf01tllrbhegjO8_m0,1263
157
- followthemoney/types/name.py,sha256=Y7g-SkmO_9PgzpBubS79OsPhp4h73dEGVql78Bq2x0Y,2258
157
+ followthemoney/types/name.py,sha256=1CzwNRgbvNpE5C9hm3PbMmJILCbpTDey5l3eL--bkTQ,2317
158
158
  followthemoney/types/number.py,sha256=OdVuHDd4IYIIHhx_317JKeMjBAGtsJ2TAcxoZKZ4MkY,3948
159
159
  followthemoney/types/phone.py,sha256=r8uRqWinS0CYnYBTs405k5gO4jeatUDgjdzzijoMKJE,3811
160
160
  followthemoney/types/string.py,sha256=fqyTauAm4mNnNaoH-yH087RBbNh-G5ZZUO3awTGQUUg,1230
161
161
  followthemoney/types/topic.py,sha256=CS5IoI8gm4MSVxfV6K4mGd20_tT1SaKMkcOt_ObSsAg,3678
162
162
  followthemoney/types/url.py,sha256=QFpS_JIV8unFHuh_uGv22SWUUkocBoOpzLsAJWom_gI,1455
163
- followthemoney-4.0.3.dist-info/METADATA,sha256=yUOxdiPQceDy0QyIYrvGxXJq4CYCplr-fSFipACeg1s,6791
164
- followthemoney-4.0.3.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
165
- followthemoney-4.0.3.dist-info/entry_points.txt,sha256=caoFTlf213jhg5sz3TNSofutjUTzaKtWATuSIdd9Cps,653
166
- followthemoney-4.0.3.dist-info/licenses/LICENSE,sha256=H6_EVXisnJC0-18CjXIaqrBSFq_VH3OnS7u3dccOv6g,1148
167
- followthemoney-4.0.3.dist-info/RECORD,,
163
+ followthemoney-4.1.0.dist-info/METADATA,sha256=aHjJGapyWo-mWOU29zQqjpGS2szD62G2D36wAraAYTE,6791
164
+ followthemoney-4.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
165
+ followthemoney-4.1.0.dist-info/entry_points.txt,sha256=caoFTlf213jhg5sz3TNSofutjUTzaKtWATuSIdd9Cps,653
166
+ followthemoney-4.1.0.dist-info/licenses/LICENSE,sha256=H6_EVXisnJC0-18CjXIaqrBSFq_VH3OnS7u3dccOv6g,1148
167
+ followthemoney-4.1.0.dist-info/RECORD,,