banks 2.3.0__py3-none-any.whl → 2.4.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.
banks/__about__.py CHANGED
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2023-present Massimiliano Pippi <mpippi@gmail.com>
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
- __version__ = "2.3.0"
4
+ __version__ = "2.4.0"
banks/filters/audio.py CHANGED
@@ -6,7 +6,9 @@ from pathlib import Path
6
6
  from typing import cast
7
7
  from urllib.parse import urlparse
8
8
 
9
- from banks.types import AudioFormat, ContentBlock, InputAudio
9
+ import filetype # type: ignore[import-untyped]
10
+
11
+ from banks.types import AudioFormat, ContentBlock, InputAudio, resolve_binary
10
12
 
11
13
  BASE64_AUDIO_REGEX = re.compile(r"audio\/.*;base64,.*")
12
14
 
@@ -38,7 +40,18 @@ def _get_audio_format_from_url(url: str) -> AudioFormat:
38
40
  return "mp3"
39
41
 
40
42
 
41
- def audio(value: str) -> str:
43
+ def _get_audio_format_from_bytes(data: bytes) -> AudioFormat:
44
+ """Extract audio format from bytes data using filetype library."""
45
+ kind = filetype.guess(data)
46
+ if kind is not None:
47
+ fmt = kind.extension
48
+ if fmt in ("mp3", "wav", "m4a", "webm", "ogg", "flac"):
49
+ return cast(AudioFormat, fmt)
50
+ # Default to mp3 if format cannot be determined
51
+ return "mp3"
52
+
53
+
54
+ def audio(value: str | bytes) -> str:
42
55
  """Wrap the filtered value into a ContentBlock of type audio.
43
56
 
44
57
  The resulting ChatMessage will have the field `content` populated with a list of ContentBlock objects.
@@ -51,7 +64,10 @@ def audio(value: str) -> str:
51
64
  {{ "https://example.com/audio.mp3" | audio }}
52
65
  ```
53
66
  """
54
- if _is_url(value):
67
+ if isinstance(value, bytes):
68
+ audio_format = _get_audio_format_from_bytes(resolve_binary(value, as_base64=False))
69
+ input_audio = InputAudio.from_bytes(value, audio_format=audio_format)
70
+ elif _is_url(value):
55
71
  audio_format = _get_audio_format_from_url(value)
56
72
  input_audio = InputAudio.from_url(value, audio_format)
57
73
  else:
banks/filters/document.py CHANGED
@@ -1,12 +1,15 @@
1
1
  # SPDX-FileCopyrightText: 2023-present Massimiliano Pippi <mpippi@gmail.com>
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
+ import mimetypes
4
5
  import re
5
6
  from pathlib import Path
6
7
  from typing import cast
7
8
  from urllib.parse import urlparse
8
9
 
9
- from banks.types import ContentBlock, DocumentFormat, InputDocument
10
+ import filetype # type: ignore[import-untyped]
11
+
12
+ from banks.types import ContentBlock, DocumentFormat, InputDocument, resolve_binary
10
13
 
11
14
  BASE64_DOCUMENT_REGEX = re.compile(r"(text|application)\/.*;base64,.*")
12
15
 
@@ -36,7 +39,7 @@ def _get_document_format_from_url(url: str) -> DocumentFormat:
36
39
  # text/css
37
40
  # text/plain
38
41
  # text/xml
39
- # text/scv
42
+ # text/csv
40
43
  # text/rtf
41
44
  # text/javascript
42
45
  # application/json
@@ -68,13 +71,46 @@ def _get_document_format_from_url(url: str) -> DocumentFormat:
68
71
  "javascript",
69
72
  "json",
70
73
  ):
74
+ # Because Claude only supports pdf and text, and Gemini only supports a small subset of text formats,
75
+ # we can default to 'txt' for any text-based format that is not pdf. This allows the data to be sent to the llm
76
+ # in an acceptable format, but the LLM should still be able to understand the content: e.g., html, markdown,
77
+ # xml, etc.
71
78
  if path.endswith(f".{fmt}"):
79
+ if fmt == "pdf":
80
+ return cast(DocumentFormat, "pdf")
81
+ return "txt"
82
+ mime = mimetypes.guess_type(path)[0]
83
+ if mime is not None and mime.startswith("text/"):
84
+ return "txt"
85
+ # With urls, the likelihood seems sufficiently high that it's probably a pdf if not otherwise indicated
86
+ if mime is None:
87
+ return "pdf"
88
+ # Document type indicated to be other than pdf or text type
89
+ raise ValueError("Unsupported document format: " + path)
90
+
91
+
92
+ def _get_document_format_from_bytes(data: bytes) -> DocumentFormat:
93
+ """Extract document format from bytes data using filetype library."""
94
+ # First check for pdf (only non text based format) and RTF formats (can be detected by file header)
95
+ kind = filetype.guess(data)
96
+ if kind is not None:
97
+ fmt = kind.extension
98
+ if fmt == "pdf":
72
99
  return cast(DocumentFormat, fmt)
73
- # Default to pdf if format cannot be determined
74
- return "pdf"
100
+
101
+ # filetype is good at detecting binary formats, but not text-based ones.
102
+ # So, this is a good indicator that it's text-based.
103
+ # Because Claude only supports pdf and text, and Gemini only supports a small subset of text formats,
104
+ # we can default to 'txt' for any text-based format that is not pdf. This allows the data to be sent to the llm in
105
+ # an acceptable format, but the LLM should still be able to understand the content: e.g., html, markdown, xml, etc.
106
+ # If detecting text types should become desirable, I recommend using something like Google magicka
107
+ if kind is None or kind.extension == "rtf":
108
+ return "txt"
109
+ # There are many common document types (like word, excel, powerpoint, etc.) that are not supported.
110
+ raise ValueError("Unsupported document format: " + kind.extension)
75
111
 
76
112
 
77
- def document(value: str) -> str:
113
+ def document(value: str | bytes) -> str:
78
114
  """Wrap the filtered value into a ContentBlock of type document.
79
115
 
80
116
  The resulting ChatMessage will have the field `content` populated with a list of ContentBlock objects.
@@ -87,7 +123,10 @@ def document(value: str) -> str:
87
123
  {{ "https://example.com/document.pdf" | document }}
88
124
  ```
89
125
  """
90
- if _is_url(value):
126
+ if isinstance(value, bytes):
127
+ document_format = _get_document_format_from_bytes(resolve_binary(value, as_base64=False))
128
+ input_document = InputDocument.from_bytes(value, document_format=document_format)
129
+ elif _is_url(value):
91
130
  document_format = _get_document_format_from_url(value)
92
131
  input_document = InputDocument.from_url(value, document_format)
93
132
  else:
banks/filters/image.py CHANGED
@@ -22,7 +22,7 @@ def _is_url(string: str) -> bool:
22
22
  return True
23
23
 
24
24
 
25
- def image(value: str) -> str:
25
+ def image(value: str | bytes) -> str:
26
26
  """Wrap the filtered value into a ContentBlock of type image.
27
27
 
28
28
  The resulting ChatMessage will have the field `content` populated with a list of ContentBlock objects.
@@ -38,7 +38,9 @@ def image(value: str) -> str:
38
38
  this filter marks the content to cache by surrounding it with `<content_block>` and
39
39
  `</content_block>`, so it's only useful when used within a `{% chat %}` block.
40
40
  """
41
- if _is_url(value):
41
+ if isinstance(value, bytes):
42
+ image_url = ImageUrl.from_bytes(bytes_str=value)
43
+ elif _is_url(value):
42
44
  image_url = ImageUrl(url=value)
43
45
  else:
44
46
  image_url = ImageUrl.from_path(Path(value))
banks/filters/video.py CHANGED
@@ -6,11 +6,39 @@ from pathlib import Path
6
6
  from typing import cast
7
7
  from urllib.parse import urlparse
8
8
 
9
- from banks.types import ContentBlock, InputVideo, VideoFormat
9
+ import filetype # type: ignore[import-untyped]
10
+ from filetype.types.video import IsoBmff # type: ignore[import-untyped]
11
+
12
+ from banks.types import ContentBlock, InputVideo, VideoFormat, resolve_binary
10
13
 
11
14
  BASE64_VIDEO_REGEX = re.compile(r"video\/.*;base64,.*")
12
15
 
13
16
 
17
+ class M3gp(IsoBmff):
18
+ """
19
+ Implements the 3gp video type matcher.
20
+
21
+ The type matcher in the filetype lib does not work correctly for 3gp files,
22
+ so implement our own here.
23
+ """
24
+
25
+ MIME = "video/3gpp"
26
+ EXTENSION = "3gp"
27
+
28
+ def __init__(self):
29
+ super().__init__(mime=M3gp.MIME, extension=M3gp.EXTENSION)
30
+
31
+ def match(self, buf):
32
+ if not self._is_isobmff(buf):
33
+ return False
34
+
35
+ major_brand, _, compatible_brands = self._get_ftyp(buf)
36
+ for brand in compatible_brands:
37
+ if brand in ["3gp4", "3gp5", "3gpp"]:
38
+ return True
39
+ return major_brand in ["3gp4", "3gp5", "3gpp"]
40
+
41
+
14
42
  def _is_url(string: str) -> bool:
15
43
  """Check if a string is a URL."""
16
44
  result = urlparse(string)
@@ -40,7 +68,22 @@ def _get_video_format_from_url(url: str) -> VideoFormat:
40
68
  return "mp4"
41
69
 
42
70
 
43
- def video(value: str) -> str:
71
+ def _get_video_format_from_bytes(data: bytes) -> VideoFormat:
72
+ """Extract video format from bytes data using filetype library."""
73
+ m3gp = M3gp()
74
+ if m3gp not in filetype.types:
75
+ filetype.add_type(m3gp)
76
+
77
+ kind = filetype.guess(data)
78
+ if kind is not None:
79
+ fmt = kind.extension
80
+ if fmt in ("mp4", "mpg", "mov", "avi", "flv", "webm", "wmv", "3gp"):
81
+ return cast(VideoFormat, fmt)
82
+ # Default to mp4 if format cannot be determined
83
+ return "mp4"
84
+
85
+
86
+ def video(value: str | bytes) -> str:
44
87
  """Wrap the filtered value into a ContentBlock of type video.
45
88
 
46
89
  The resulting ChatMessage will have the field `content` populated with a list of ContentBlock objects.
@@ -53,7 +96,10 @@ def video(value: str) -> str:
53
96
  {{ "https://example.com/video.mp4" | video }}
54
97
  ```
55
98
  """
56
- if _is_url(value):
99
+ if isinstance(value, bytes):
100
+ video_format = _get_video_format_from_bytes(resolve_binary(value, as_base64=False))
101
+ input_video = InputVideo.from_bytes(value, video_format=video_format)
102
+ elif _is_url(value):
57
103
  video_format = _get_video_format_from_url(value)
58
104
  input_video = InputVideo.from_url(value, video_format)
59
105
  else:
banks/types.py CHANGED
@@ -5,11 +5,14 @@ from __future__ import annotations
5
5
 
6
6
  import base64
7
7
  import re
8
+ from base64 import b64decode, b64encode
9
+ from binascii import Error as BinasciiError
8
10
  from enum import Enum
9
11
  from inspect import Parameter, getdoc, signature
10
12
  from pathlib import Path
11
13
  from typing import Callable, Literal, Union, cast
12
14
 
15
+ import filetype # type: ignore[import-untyped]
13
16
  from pydantic import BaseModel
14
17
  from typing_extensions import Self
15
18
 
@@ -19,6 +22,31 @@ from .utils import parse_params_from_docstring, python_type_to_jsonschema
19
22
  CONTENT_BLOCK_REGEX = re.compile(r"(<content_block>\{.*?\}<\/content_block>)|([^<](?:(?!<content_block>)[\s\S])*)")
20
23
 
21
24
 
25
+ def resolve_binary(bytes_str: bytes, *, as_base64: bool = True) -> bytes:
26
+ """
27
+ Resolve binary data between base64 and raw bytes.
28
+
29
+ Args:
30
+ bytes_str: Bytes data
31
+ as_base64: Whether to return base64 encoded bytes or raw bytes
32
+
33
+ Returns:
34
+ b64 encoded bytes if input is not base64 encoded, else returns input as is.
35
+ """
36
+ # check if bytes_str is base64 encoded
37
+ try:
38
+ # Check if raw_bytes is already base64 encoded.
39
+ # b64decode() can succeed on random binary data, so we
40
+ # pass verify=True to make sure it's not a false positive
41
+ raw_bytes = base64.b64decode(bytes_str, validate=True)
42
+ b64_bytes = bytes_str
43
+ except BinasciiError:
44
+ # b64decode failed, leave as is
45
+ raw_bytes = bytes_str
46
+ b64_bytes = b64encode(bytes_str)
47
+ return b64_bytes if as_base64 else raw_bytes
48
+
49
+
22
50
  class ContentBlockType(str, Enum):
23
51
  text = "text"
24
52
  image_url = "image_url"
@@ -34,6 +62,14 @@ class CacheControl(BaseModel):
34
62
  class ImageUrl(BaseModel):
35
63
  url: str
36
64
 
65
+ @staticmethod
66
+ def _mimetype_from_bytes(raw_bytes: bytes) -> str:
67
+ kind = filetype.guess(raw_bytes)
68
+ if kind is not None:
69
+ return kind.mime
70
+ # Default to jpeg if format cannot be determined
71
+ return "image/jpeg"
72
+
37
73
  @classmethod
38
74
  def from_base64(cls, media_type: str, base64_str: str) -> Self:
39
75
  return cls(url=f"data:{media_type};base64,{base64_str}")
@@ -41,12 +77,31 @@ class ImageUrl(BaseModel):
41
77
  @classmethod
42
78
  def from_path(cls, file_path: Path) -> Self:
43
79
  with open(file_path, "rb") as image_file:
44
- return cls.from_base64("image/jpeg", base64.b64encode(image_file.read()).decode("utf-8"))
80
+ raw_bytes = image_file.read()
81
+ mimetype = cls._mimetype_from_bytes(raw_bytes)
82
+ return cls.from_base64(mimetype, base64.b64encode(raw_bytes).decode("utf-8"))
83
+
84
+ @classmethod
85
+ def from_bytes(cls, bytes_str: bytes) -> Self:
86
+ """Create ImageUrl from bytes
87
+ Args:
88
+ bytes_str: Bytes data
89
+ Returns:
90
+ ImageUrl instance with base64 encoded bytes as URL
91
+ """
92
+ b64_bytes = resolve_binary(bytes_str)
93
+ mimetype = cls._mimetype_from_bytes(b64decode(b64_bytes))
94
+ return cls.from_base64(mimetype, b64_bytes.decode("utf-8"))
45
95
 
46
96
 
47
97
  AudioFormat = Literal["mp3", "wav", "m4a", "webm", "ogg", "flac"]
48
- VideoFormat = Literal["mp4", "mpeg", "mov", "avi", "flv", "mpg", "webm", "wmv", "3gpp"]
49
- DocumentFormat = Literal["pdf", "html", "css", "plain", "xml", "csv", "rtf", "javascript", "json"]
98
+ VideoFormat = Literal["mp4", "mpg", "mov", "avi", "flv", "webm", "wmv", "3gp", "3gpp"]
99
+ # Because Claude only supports pdf and text, and Gemini only supports a small subset of text formats,
100
+ # we can default to 'txt' for any text-based format that is not pdf. This allows the data to be sent to the llm
101
+ # in an acceptable format, but the LLM should still be able to understand the content: e.g., html, markdown,
102
+ # xml, etc.
103
+ # If detecting text types should become desirable, I recommend using something like Google magicka
104
+ DocumentFormat = Literal["pdf", "txt"]
50
105
 
51
106
 
52
107
  class InputAudio(BaseModel):
@@ -73,6 +128,21 @@ class InputAudio(BaseModel):
73
128
  """
74
129
  return cls(data=url, format=audio_format)
75
130
 
131
+ @classmethod
132
+ def from_bytes(cls, bytes_str: bytes, audio_format: AudioFormat) -> Self:
133
+ """Create InputAudio from bytes
134
+
135
+ Args:
136
+ bytes_str: Bytes data
137
+ audio_format: The audio format
138
+
139
+ Returns:
140
+ InputAudio instance with base64 encoded bytes as data
141
+ """
142
+ b64_bytes = resolve_binary(bytes_str)
143
+ encoded_str = b64_bytes.decode("utf-8")
144
+ return cls(data=encoded_str, format=audio_format)
145
+
76
146
 
77
147
  class InputVideo(BaseModel):
78
148
  data: str
@@ -98,6 +168,21 @@ class InputVideo(BaseModel):
98
168
  """
99
169
  return cls(data=url, format=video_format)
100
170
 
171
+ @classmethod
172
+ def from_bytes(cls, bytes_str: bytes, video_format: VideoFormat) -> Self:
173
+ """Create InputVideo from bytes
174
+
175
+ Args:
176
+ bytes_str: Bytes data
177
+ video_format: The video format
178
+
179
+ Returns:
180
+ InputVideo instance with base64 encoded bytes as data
181
+ """
182
+ b64_bytes = resolve_binary(bytes_str)
183
+ encoded_str = b64_bytes.decode("utf-8")
184
+ return cls(data=encoded_str, format=video_format)
185
+
101
186
 
102
187
  class InputDocument(BaseModel):
103
188
  data: str
@@ -123,6 +208,21 @@ class InputDocument(BaseModel):
123
208
  """
124
209
  return cls(data=url, format=document_format)
125
210
 
211
+ @classmethod
212
+ def from_bytes(cls, bytes_str: bytes, document_format: DocumentFormat) -> Self:
213
+ """Create InputDocument from bytes
214
+
215
+ Args:
216
+ bytes_str: Bytes data
217
+ document_format: The document format
218
+
219
+ Returns:
220
+ InputDocument instance with base64 encoded bytes as data
221
+ """
222
+ b64_bytes = resolve_binary(bytes_str)
223
+ encoded_str = b64_bytes.decode("utf-8")
224
+ return cls(data=encoded_str, format=document_format)
225
+
126
226
 
127
227
  class ContentBlock(BaseModel):
128
228
  type: ContentBlockType
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: banks
3
- Version: 2.3.0
3
+ Version: 2.4.0
4
4
  Summary: A prompt programming language
5
5
  Project-URL: Documentation, https://github.com/masci/banks#readme
6
6
  Project-URL: Issues, https://github.com/masci/banks/issues
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: Implementation :: PyPy
21
21
  Requires-Python: >=3.9
22
22
  Requires-Dist: deprecated
23
23
  Requires-Dist: eval-type-backport; python_version < '3.10'
24
+ Requires-Dist: filetype>=1.2.0
24
25
  Requires-Dist: griffe
25
26
  Requires-Dist: jinja2
26
27
  Requires-Dist: platformdirs
@@ -1,30 +1,30 @@
1
- banks/__about__.py,sha256=k_flpZerbrh6w78dg4-Rmizti2D8Fd5uyalbWC-tEZg,132
1
+ banks/__about__.py,sha256=Kbqara7VPF_4Txd0QVMWDNL5EqtjtYKEQFgzvx2Dqgc,132
2
2
  banks/__init__.py,sha256=4IBopxXstFZliCvSjOuTurSQb32Vy26EXOPhmNZ4Hus,334
3
3
  banks/cache.py,sha256=uUGAu82-mfrscc2q24x19ZMZBkoQzf3hh7_V300J-Ik,1069
4
4
  banks/config.py,sha256=Ry2pdba1pgepsfmj41hxMdfQCT9pMjdV6TRWySpMiSY,1192
5
5
  banks/env.py,sha256=oGC4wjmF0-NTwoi49NooCLqqT44htn6EWDnHsDl2n0I,1347
6
6
  banks/errors.py,sha256=EnKRBhHmo8KEcSg3YoDBtVEaooJj9uSqRV1wnzUtrJU,580
7
7
  banks/prompt.py,sha256=LXaDGHBywFkD_JmWPQOXqyuQKZgozzDIb5lSZ12xf5A,8505
8
- banks/types.py,sha256=34YzR2Dzf7gpDlpODv-pHFZsGbNxt5UYWNxkih1wo1U,7747
8
+ banks/types.py,sha256=p74ZUJfH0vZnneOEsAmiHoDGfsbjNLO-VDHYPuov2Yo,11347
9
9
  banks/utils.py,sha256=ZetGG3qhXMYOitDZQCWbE33wHEqR0ih2ZEg_dIW8OeI,1827
10
10
  banks/extensions/__init__.py,sha256=Lx4UrOzywYQY7a8qvIqvc3ql54nwK0lNP7x3jYdbREY,110
11
11
  banks/extensions/chat.py,sha256=VV6UV1wQZcJ0KbIFHSFmDeptWtww4o2IXF5pXB6TpTM,2478
12
12
  banks/extensions/completion.py,sha256=p6NdzA5kOuWZ0BIcGQH86Ji4Z4PFz0-h_G2cHgKdYvw,7861
13
13
  banks/extensions/docs.py,sha256=vWOZvu2JoS4LwUG-BR3jPqThirYvu3Fdba331UxooYM,1098
14
14
  banks/filters/__init__.py,sha256=fcAlKqgDSX19JDQHfTTOtpotxbdC84QbcFF3dKTtEog,430
15
- banks/filters/audio.py,sha256=H_kOKhVKk1BdKmVgia682jn_09FInGjehEtXuUtXZ6Y,1900
15
+ banks/filters/audio.py,sha256=x1mWEpzSN2mc_HAUEaNEOUgS_Vh7Wa1VPwRFkRy4oG0,2574
16
16
  banks/filters/cache_control.py,sha256=aOGOIzuqasV_TcuFaaXbaoGhA2W9YTFuz7wkatyjXRU,962
17
- banks/filters/document.py,sha256=EhewpOgQ8ZW-vE1iaXQ-N0L6_xdWY-qHIp0VqL8wQDQ,2600
18
- banks/filters/image.py,sha256=Ls1fWCgRx0YLGIFx7hdKtR1skY575jDWlCESP0zV1Bs,1407
17
+ banks/filters/document.py,sha256=hs2IO6d-xcLyTHH50bpzA3848bJLpQYjUkEgjOBYGqE,4893
18
+ banks/filters/image.py,sha256=0t4u2El2Gi92C1qlY_0ji5OpPnjJfTn67SXb2mCIOl8,1507
19
19
  banks/filters/lemmatize.py,sha256=Yvp8M4HCx6C0nrcu3UEMtjJUwsyVYI6GQDYOG4S6EEw,887
20
20
  banks/filters/tool.py,sha256=i8ukSDYw54ksShVJ2abfRQAiKzKrqUtmgBB1H04cig0,475
21
- banks/filters/video.py,sha256=mD4iLWfFeihccqhFIr3Vq2_3ymF1rLMMuYzvTHEBlgk,2024
21
+ banks/filters/video.py,sha256=MFni5um9Xnq8Sxf6ZBTN5GsKAC6f73CLrqFAGaE2pkk,3531
22
22
  banks/filters/xml.py,sha256=uQ_2zfCf8NhpdbF8F5HS7URXvDzsxfg-TEIVGufZbM0,1991
23
23
  banks/registries/__init__.py,sha256=iRK-8420cKBckOTd5KcIFQyV66EsF0Mc7UHCkzf8qZU,255
24
24
  banks/registries/directory.py,sha256=gRFO7fl9yXHt2NJ1pDA2wPSQtlORhSw1GKWxSTyFzE8,6055
25
25
  banks/registries/file.py,sha256=8ayvFrcM8Tk0DWgGXmKD2DRBfGXr5CmgtdQaQ5cXhow,4054
26
26
  banks/registries/redis.py,sha256=eBL92URJa-NegOxRLS4b2xrDRDxz6iiaz_7Ddi32Rtc,2756
27
- banks-2.3.0.dist-info/METADATA,sha256=-zwz3vftKvn3mKTVlICOKu7u4JEuyiEf8idRQf1-BEg,12227
28
- banks-2.3.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
29
- banks-2.3.0.dist-info/licenses/LICENSE.txt,sha256=NZJne_JTwMFwq_g-kq-sm4PuaeVOgu1l3NUGOgBHX-g,1102
30
- banks-2.3.0.dist-info/RECORD,,
27
+ banks-2.4.0.dist-info/METADATA,sha256=D94dNSjkJj9vXMecMp7lTiGS_7sM4ipxyRvtbkn8aYs,12258
28
+ banks-2.4.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
29
+ banks-2.4.0.dist-info/licenses/LICENSE.txt,sha256=NZJne_JTwMFwq_g-kq-sm4PuaeVOgu1l3NUGOgBHX-g,1102
30
+ banks-2.4.0.dist-info/RECORD,,
File without changes