banks 2.2.0__py3-none-any.whl → 2.3.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.2.0"
4
+ __version__ = "2.3.0"
banks/config.py CHANGED
@@ -28,9 +28,12 @@ class _BanksConfig:
28
28
  return original_value
29
29
 
30
30
  # Convert string from env var to the actual type
31
- t = super().__getattribute__("__annotations__")[name]
31
+ annotations = getattr(type(self), "__annotations__", {})
32
+ t = annotations.get(name, type(original_value))
32
33
  if t is bool:
33
34
  return strtobool(read_value)
35
+ if t is Any:
36
+ return read_value
34
37
 
35
38
  return t(read_value)
36
39
 
banks/env.py CHANGED
@@ -4,7 +4,7 @@
4
4
  from jinja2 import Environment, select_autoescape
5
5
 
6
6
  from .config import config
7
- from .filters import audio, cache_control, image, lemmatize, tool, xml
7
+ from .filters import audio, cache_control, document, image, lemmatize, tool, video, xml
8
8
 
9
9
 
10
10
  def _add_extensions(_env):
@@ -38,6 +38,8 @@ env.filters["image"] = image
38
38
  env.filters["lemmatize"] = lemmatize
39
39
  env.filters["tool"] = tool
40
40
  env.filters["audio"] = audio
41
+ env.filters["video"] = video
42
+ env.filters["document"] = document
41
43
  env.filters["to_xml"] = xml
42
44
 
43
45
  _add_extensions(env)
banks/errors.py CHANGED
@@ -14,7 +14,7 @@ class CanaryWordError(Exception):
14
14
 
15
15
 
16
16
  class PromptNotFoundError(Exception):
17
- """The prompt was now found in the registry."""
17
+ """The prompt was not found in the registry."""
18
18
 
19
19
 
20
20
  class InvalidPromptError(Exception):
@@ -3,7 +3,7 @@
3
3
  # SPDX-License-Identifier: MIT
4
4
  import importlib
5
5
  import json
6
- from typing import cast
6
+ from typing import TYPE_CHECKING, Any, Callable, cast
7
7
 
8
8
  from jinja2 import TemplateSyntaxError, nodes
9
9
  from jinja2.ext import Extension
@@ -12,6 +12,8 @@ from pydantic import ValidationError
12
12
  from banks.errors import InvalidPromptError, LLMError
13
13
  from banks.types import ChatMessage, Tool
14
14
 
15
+ if TYPE_CHECKING:
16
+ from litellm.types.utils import ChatCompletionMessageToolCall
15
17
  SUPPORTED_KWARGS = ("model",)
16
18
  LITELLM_INSTALL_MSG = "litellm is not installed. Please install it with `pip install litellm`."
17
19
 
@@ -74,7 +76,19 @@ class CompletionExtension(Extension):
74
76
  return nodes.CallBlock(self.call_method("_do_completion_async", args), [], [], body).set_lineno(lineno)
75
77
  return nodes.CallBlock(self.call_method("_do_completion", args), [], [], body).set_lineno(lineno)
76
78
 
77
- def _get_tool_callable(self, tools, tool_call):
79
+ def _get_tool_callable(self, tools: list[Tool], tool_call: "ChatCompletionMessageToolCall") -> Callable[..., Any]:
80
+ """Get the callable function for a tool call.
81
+
82
+ Args:
83
+ tools: List of available tools
84
+ tool_call: The tool call from the LLM response
85
+
86
+ Returns:
87
+ The callable function
88
+
89
+ Raises:
90
+ ValueError: If the function is not found in available tools
91
+ """
78
92
  for tool in tools:
79
93
  if tool.function.name == tool_call.function.name:
80
94
  module_name, func_name = tool.import_path.rsplit(".", maxsplit=1)
banks/filters/__init__.py CHANGED
@@ -3,9 +3,11 @@
3
3
  # SPDX-License-Identifier: MIT
4
4
  from .audio import audio
5
5
  from .cache_control import cache_control
6
+ from .document import document
6
7
  from .image import image
7
8
  from .lemmatize import lemmatize
8
9
  from .tool import tool
10
+ from .video import video
9
11
  from .xml import xml
10
12
 
11
- __all__ = ("cache_control", "image", "lemmatize", "tool", "audio", "xml")
13
+ __all__ = ("cache_control", "image", "lemmatize", "tool", "audio", "video", "document", "xml")
banks/filters/audio.py CHANGED
@@ -1,9 +1,41 @@
1
1
  # SPDX-FileCopyrightText: 2023-present Massimiliano Pippi <mpippi@gmail.com>
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
+ import re
4
5
  from pathlib import Path
6
+ from typing import cast
7
+ from urllib.parse import urlparse
5
8
 
6
- from banks.types import ContentBlock, InputAudio
9
+ from banks.types import AudioFormat, ContentBlock, InputAudio
10
+
11
+ BASE64_AUDIO_REGEX = re.compile(r"audio\/.*;base64,.*")
12
+
13
+
14
+ def _is_url(string: str) -> bool:
15
+ """Check if a string is a URL."""
16
+ result = urlparse(string)
17
+ if not result.scheme:
18
+ return False
19
+
20
+ if not result.netloc:
21
+ # The only valid format when netloc is empty is base64 data urls
22
+ return all([result.scheme == "data", BASE64_AUDIO_REGEX.match(result.path)])
23
+
24
+ return True
25
+
26
+
27
+ def _get_audio_format_from_url(url: str) -> AudioFormat:
28
+ """Extract audio format from URL.
29
+
30
+ Tries to determine format from URL path or defaults to mp3.
31
+ """
32
+ parsed = urlparse(url)
33
+ path = parsed.path.lower()
34
+ for fmt in ("mp3", "wav", "m4a", "webm", "ogg", "flac"):
35
+ if path.endswith(f".{fmt}"):
36
+ return cast(AudioFormat, fmt)
37
+ # Default to mp3 if format cannot be determined
38
+ return "mp3"
7
39
 
8
40
 
9
41
  def audio(value: str) -> str:
@@ -11,13 +43,18 @@ def audio(value: str) -> str:
11
43
 
12
44
  The resulting ChatMessage will have the field `content` populated with a list of ContentBlock objects.
13
45
 
46
+ Supports both file paths and URLs (including data URLs).
47
+
14
48
  Example:
15
49
  ```jinja
16
- Describe what you see
17
-
18
- {{ "path/to/audio/file" | audio }}
50
+ {{ "path/to/audio/file.mp3" | audio }}
51
+ {{ "https://example.com/audio.mp3" | audio }}
19
52
  ```
20
53
  """
21
- input_audio = InputAudio.from_path(Path(value))
54
+ if _is_url(value):
55
+ audio_format = _get_audio_format_from_url(value)
56
+ input_audio = InputAudio.from_url(value, audio_format)
57
+ else:
58
+ input_audio = InputAudio.from_path(Path(value))
22
59
  block = ContentBlock.model_validate({"type": "audio", "input_audio": input_audio})
23
60
  return f"<content_block>{block.model_dump_json()}</content_block>"
@@ -0,0 +1,96 @@
1
+ # SPDX-FileCopyrightText: 2023-present Massimiliano Pippi <mpippi@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ import re
5
+ from pathlib import Path
6
+ from typing import cast
7
+ from urllib.parse import urlparse
8
+
9
+ from banks.types import ContentBlock, DocumentFormat, InputDocument
10
+
11
+ BASE64_DOCUMENT_REGEX = re.compile(r"(text|application)\/.*;base64,.*")
12
+
13
+
14
+ def _is_url(string: str) -> bool:
15
+ """Check if a string is a URL."""
16
+ result = urlparse(string)
17
+ if not result.scheme:
18
+ return False
19
+
20
+ if not result.netloc:
21
+ # The only valid format when netloc is empty is base64 data urls
22
+ return all([result.scheme == "data", BASE64_DOCUMENT_REGEX.match(result.path)])
23
+
24
+ return True
25
+
26
+
27
+ def _get_document_format_from_url(url: str) -> DocumentFormat:
28
+ """Extract document format from URL.
29
+
30
+ Tries to determine format from URL path or defaults to pdf.
31
+ """
32
+ parsed = urlparse(url)
33
+ path = parsed.path.lower()
34
+ # Gemini supported file types https://ai.google.dev/gemini-api/docs/file-input-methods
35
+ # text/html
36
+ # text/css
37
+ # text/plain
38
+ # text/xml
39
+ # text/scv
40
+ # text/rtf
41
+ # text/javascript
42
+ # application/json
43
+ # application/pdf
44
+
45
+ # Claude supported file types
46
+ # application/pdf
47
+ # text/plain
48
+
49
+ # OpenAI supported file types
50
+ # application/pdf
51
+
52
+ for fmt in (
53
+ "pdf",
54
+ "html",
55
+ "htm",
56
+ "xhtml",
57
+ "css",
58
+ "txt",
59
+ "md",
60
+ "markdown",
61
+ "rst",
62
+ "xml",
63
+ "csv",
64
+ "rtf",
65
+ "js",
66
+ "mjs",
67
+ "cjs",
68
+ "javascript",
69
+ "json",
70
+ ):
71
+ if path.endswith(f".{fmt}"):
72
+ return cast(DocumentFormat, fmt)
73
+ # Default to pdf if format cannot be determined
74
+ return "pdf"
75
+
76
+
77
+ def document(value: str) -> str:
78
+ """Wrap the filtered value into a ContentBlock of type document.
79
+
80
+ The resulting ChatMessage will have the field `content` populated with a list of ContentBlock objects.
81
+
82
+ Supports both file paths and URLs (including data URLs).
83
+
84
+ Example:
85
+ ```jinja
86
+ {{ "path/to/document/file.pdf" | document }}
87
+ {{ "https://example.com/document.pdf" | document }}
88
+ ```
89
+ """
90
+ if _is_url(value):
91
+ document_format = _get_document_format_from_url(value)
92
+ input_document = InputDocument.from_url(value, document_format)
93
+ else:
94
+ input_document = InputDocument.from_path(Path(value))
95
+ block = ContentBlock.model_validate({"type": "document", "input_document": input_document})
96
+ return f"<content_block>{block.model_dump_json()}</content_block>"
banks/filters/video.py ADDED
@@ -0,0 +1,62 @@
1
+ # SPDX-FileCopyrightText: 2023-present Massimiliano Pippi <mpippi@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ import re
5
+ from pathlib import Path
6
+ from typing import cast
7
+ from urllib.parse import urlparse
8
+
9
+ from banks.types import ContentBlock, InputVideo, VideoFormat
10
+
11
+ BASE64_VIDEO_REGEX = re.compile(r"video\/.*;base64,.*")
12
+
13
+
14
+ def _is_url(string: str) -> bool:
15
+ """Check if a string is a URL."""
16
+ result = urlparse(string)
17
+ if not result.scheme:
18
+ return False
19
+
20
+ if not result.netloc:
21
+ # The only valid format when netloc is empty is base64 data urls
22
+ return all([result.scheme == "data", BASE64_VIDEO_REGEX.match(result.path)])
23
+
24
+ return True
25
+
26
+
27
+ def _get_video_format_from_url(url: str) -> VideoFormat:
28
+ """Extract video format from URL.
29
+
30
+ Tries to determine format from URL path or defaults to mp4.
31
+ """
32
+ parsed = urlparse(url)
33
+ path = parsed.path.lower()
34
+
35
+ # Based on formats supported by Gemini https://ai.google.dev/gemini-api/docs/video-understanding
36
+ for fmt in ("mp4", "mpeg", "mov", "avi", "flv", "mpg", "webm", "wmv", "3gpp"):
37
+ if path.endswith(f".{fmt}"):
38
+ return cast(VideoFormat, fmt)
39
+ # Default to mp4 if format cannot be determined
40
+ return "mp4"
41
+
42
+
43
+ def video(value: str) -> str:
44
+ """Wrap the filtered value into a ContentBlock of type video.
45
+
46
+ The resulting ChatMessage will have the field `content` populated with a list of ContentBlock objects.
47
+
48
+ Supports both file paths and URLs (including data URLs).
49
+
50
+ Example:
51
+ ```jinja
52
+ {{ "path/to/video/file.mp4" | video }}
53
+ {{ "https://example.com/video.mp4" | video }}
54
+ ```
55
+ """
56
+ if _is_url(value):
57
+ video_format = _get_video_format_from_url(value)
58
+ input_video = InputVideo.from_url(value, video_format)
59
+ else:
60
+ input_video = InputVideo.from_path(Path(value))
61
+ block = ContentBlock.model_validate({"type": "video", "input_video": input_video})
62
+ return f"<content_block>{block.model_dump_json()}</content_block>"
banks/prompt.py CHANGED
@@ -81,8 +81,17 @@ class BasePrompt:
81
81
 
82
82
  @property
83
83
  def variables(self) -> set[str]:
84
- ast = env.parse(self.raw)
85
- return meta.find_undeclared_variables(ast)
84
+ try:
85
+ ast = env.parse(self.raw)
86
+ return meta.find_undeclared_variables(ast)
87
+ except Exception as e:
88
+ from jinja2 import TemplateSyntaxError
89
+
90
+ if isinstance(e, TemplateSyntaxError):
91
+ raise
92
+ # Re-raise as TemplateSyntaxError for consistency
93
+ msg = f"Failed to parse template: {e}"
94
+ raise TemplateSyntaxError(msg, 0) from e
86
95
 
87
96
  def canary_leaked(self, text: str) -> bool:
88
97
  """Returns whether the canary word is present in `text`, signalling the prompt might have leaked."""
banks/types.py CHANGED
@@ -23,6 +23,8 @@ class ContentBlockType(str, Enum):
23
23
  text = "text"
24
24
  image_url = "image_url"
25
25
  audio = "audio"
26
+ video = "video"
27
+ document = "document"
26
28
 
27
29
 
28
30
  class CacheControl(BaseModel):
@@ -43,6 +45,8 @@ class ImageUrl(BaseModel):
43
45
 
44
46
 
45
47
  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"]
46
50
 
47
51
 
48
52
  class InputAudio(BaseModel):
@@ -56,6 +60,69 @@ class InputAudio(BaseModel):
56
60
  file_format = cast(AudioFormat, file_path.suffix[1:])
57
61
  return cls(data=encoded_str, format=file_format)
58
62
 
63
+ @classmethod
64
+ def from_url(cls, url: str, audio_format: AudioFormat) -> Self:
65
+ """Create InputAudio from a URL.
66
+
67
+ Args:
68
+ url: The URL to the audio file
69
+ audio_format: The audio format
70
+
71
+ Returns:
72
+ InputAudio instance with the URL as data
73
+ """
74
+ return cls(data=url, format=audio_format)
75
+
76
+
77
+ class InputVideo(BaseModel):
78
+ data: str
79
+ format: VideoFormat
80
+
81
+ @classmethod
82
+ def from_path(cls, file_path: Path) -> Self:
83
+ with open(file_path, "rb") as video_file:
84
+ encoded_str = base64.b64encode(video_file.read()).decode("utf-8")
85
+ file_format = cast(VideoFormat, file_path.suffix[1:])
86
+ return cls(data=encoded_str, format=file_format)
87
+
88
+ @classmethod
89
+ def from_url(cls, url: str, video_format: VideoFormat) -> Self:
90
+ """Create InputVideo from a URL.
91
+
92
+ Args:
93
+ url: The URL to the audio file
94
+ video_format: The audio format
95
+
96
+ Returns:
97
+ InputVideo instance with the URL as data
98
+ """
99
+ return cls(data=url, format=video_format)
100
+
101
+
102
+ class InputDocument(BaseModel):
103
+ data: str
104
+ format: DocumentFormat
105
+
106
+ @classmethod
107
+ def from_path(cls, file_path: Path) -> Self:
108
+ with open(file_path, "rb") as document_file:
109
+ encoded_str = base64.b64encode(document_file.read()).decode("utf-8")
110
+ file_format = cast(DocumentFormat, file_path.suffix[1:])
111
+ return cls(data=encoded_str, format=file_format)
112
+
113
+ @classmethod
114
+ def from_url(cls, url: str, document_format: DocumentFormat) -> Self:
115
+ """Create InputDocument from a URL.
116
+
117
+ Args:
118
+ url: The URL to the document file
119
+ document_format: The document format
120
+
121
+ Returns:
122
+ InputDocument instance with the URL as data
123
+ """
124
+ return cls(data=url, format=document_format)
125
+
59
126
 
60
127
  class ContentBlock(BaseModel):
61
128
  type: ContentBlockType
@@ -63,6 +130,8 @@ class ContentBlock(BaseModel):
63
130
  text: str | None = None
64
131
  image_url: ImageUrl | None = None
65
132
  input_audio: InputAudio | None = None
133
+ input_video: InputVideo | None = None
134
+ input_document: InputDocument | None = None
66
135
 
67
136
 
68
137
  ChatMessageContent = Union[list[ContentBlock], str]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: banks
3
- Version: 2.2.0
3
+ Version: 2.3.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
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.10
15
15
  Classifier: Programming Language :: Python :: 3.11
16
16
  Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
18
19
  Classifier: Programming Language :: Python :: Implementation :: CPython
19
20
  Classifier: Programming Language :: Python :: Implementation :: PyPy
20
21
  Requires-Python: >=3.9
@@ -125,11 +126,11 @@ print(p.chat_messages({"persona": "helpful assistant"}))
125
126
  # [
126
127
  # ChatMessage(role='system', content=[
127
128
  # ContentBlock(type=<ContentBlockType.text: 'text'>, cache_control=None, text='You are a helpful assistant.',
128
- # image_url=None, input_audio=None)
129
+ # image_url=None, input_audio=None, input_video=None, input_document=None)
129
130
  # ], tool_call_id=None, name=None),
130
131
  # ChatMessage(role='user', content=[
131
132
  # ContentBlock(type=<ContentBlockType.text: 'text'>, cache_control=None, text='Hello, how are you?',
132
- # image_url=None, input_audio=None)
133
+ # image_url=None, input_audio=None, input_video=None, input_document=None)
133
134
  # ], tool_call_id=None, name=None)
134
135
  # ]
135
136
  ```
@@ -1,28 +1,30 @@
1
- banks/__about__.py,sha256=77xoXWQ484-a4X30IBRswGGTfRx8t_BwItzAm7NTXwI,132
1
+ banks/__about__.py,sha256=k_flpZerbrh6w78dg4-Rmizti2D8Fd5uyalbWC-tEZg,132
2
2
  banks/__init__.py,sha256=4IBopxXstFZliCvSjOuTurSQb32Vy26EXOPhmNZ4Hus,334
3
3
  banks/cache.py,sha256=uUGAu82-mfrscc2q24x19ZMZBkoQzf3hh7_V300J-Ik,1069
4
- banks/config.py,sha256=c6B1cXUZ-NN0XmJvfezXeHPXHP7knk8TfbmcZL7gCzk,1082
5
- banks/env.py,sha256=XOSz6QGNSRaqnIdKWhH5U-ci8Tfi1mDyJHit_aE27Ro,1266
6
- banks/errors.py,sha256=I5cgsa7wtolRVKBSq_aH5xs27yVcErBlMyUswCnM-es,580
7
- banks/prompt.py,sha256=RhPq3wpE-AiCfCftZpPFj2HXGdazwYD502Pr1e-j7FY,8162
8
- banks/types.py,sha256=03x7E7FPVfuN39xY--c0fKumnyVUVzNrq9pgG5R-pAU,5520
4
+ banks/config.py,sha256=Ry2pdba1pgepsfmj41hxMdfQCT9pMjdV6TRWySpMiSY,1192
5
+ banks/env.py,sha256=oGC4wjmF0-NTwoi49NooCLqqT44htn6EWDnHsDl2n0I,1347
6
+ banks/errors.py,sha256=EnKRBhHmo8KEcSg3YoDBtVEaooJj9uSqRV1wnzUtrJU,580
7
+ banks/prompt.py,sha256=LXaDGHBywFkD_JmWPQOXqyuQKZgozzDIb5lSZ12xf5A,8505
8
+ banks/types.py,sha256=34YzR2Dzf7gpDlpODv-pHFZsGbNxt5UYWNxkih1wo1U,7747
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
- banks/extensions/completion.py,sha256=kF55PiNxjqpslUTAd46H4jOy0eFiLLm5hEcwxS4_oxs,7356
12
+ banks/extensions/completion.py,sha256=p6NdzA5kOuWZ0BIcGQH86Ji4Z4PFz0-h_G2cHgKdYvw,7861
13
13
  banks/extensions/docs.py,sha256=vWOZvu2JoS4LwUG-BR3jPqThirYvu3Fdba331UxooYM,1098
14
- banks/filters/__init__.py,sha256=MMNxopwecFHW4LA76NwL2JQkdddIAGbKOaHUHG1JQs8,353
15
- banks/filters/audio.py,sha256=2vTPdpDo8FVQsl0WiPlXskwMCGnF8zKwWXfq1fYQzws,726
14
+ banks/filters/__init__.py,sha256=fcAlKqgDSX19JDQHfTTOtpotxbdC84QbcFF3dKTtEog,430
15
+ banks/filters/audio.py,sha256=H_kOKhVKk1BdKmVgia682jn_09FInGjehEtXuUtXZ6Y,1900
16
16
  banks/filters/cache_control.py,sha256=aOGOIzuqasV_TcuFaaXbaoGhA2W9YTFuz7wkatyjXRU,962
17
+ banks/filters/document.py,sha256=EhewpOgQ8ZW-vE1iaXQ-N0L6_xdWY-qHIp0VqL8wQDQ,2600
17
18
  banks/filters/image.py,sha256=Ls1fWCgRx0YLGIFx7hdKtR1skY575jDWlCESP0zV1Bs,1407
18
19
  banks/filters/lemmatize.py,sha256=Yvp8M4HCx6C0nrcu3UEMtjJUwsyVYI6GQDYOG4S6EEw,887
19
20
  banks/filters/tool.py,sha256=i8ukSDYw54ksShVJ2abfRQAiKzKrqUtmgBB1H04cig0,475
21
+ banks/filters/video.py,sha256=mD4iLWfFeihccqhFIr3Vq2_3ymF1rLMMuYzvTHEBlgk,2024
20
22
  banks/filters/xml.py,sha256=uQ_2zfCf8NhpdbF8F5HS7URXvDzsxfg-TEIVGufZbM0,1991
21
23
  banks/registries/__init__.py,sha256=iRK-8420cKBckOTd5KcIFQyV66EsF0Mc7UHCkzf8qZU,255
22
24
  banks/registries/directory.py,sha256=gRFO7fl9yXHt2NJ1pDA2wPSQtlORhSw1GKWxSTyFzE8,6055
23
25
  banks/registries/file.py,sha256=8ayvFrcM8Tk0DWgGXmKD2DRBfGXr5CmgtdQaQ5cXhow,4054
24
26
  banks/registries/redis.py,sha256=eBL92URJa-NegOxRLS4b2xrDRDxz6iiaz_7Ddi32Rtc,2756
25
- banks-2.2.0.dist-info/METADATA,sha256=m2W5swzWGUFGr0uNCFAgOqFtxv4A7fMyjsC34GDBTsk,12098
26
- banks-2.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
27
- banks-2.2.0.dist-info/licenses/LICENSE.txt,sha256=NZJne_JTwMFwq_g-kq-sm4PuaeVOgu1l3NUGOgBHX-g,1102
28
- banks-2.2.0.dist-info/RECORD,,
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.27.0
2
+ Generator: hatchling 1.28.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any