# gram_tools
Utilities for streamlined [aiogram 3](https://github.com/aiogram/aiogram) bot development.
## Features
- 📦 **Message Packing**: Serialize and deserialize Telegram messages with support for all media types
- 🗄️ **Asset Management**: Efficient handling of media files with caching and file_id reuse
- 📚 **CRUD Operations**: Generic async CRUD operations for SQLAlchemy models
- ⌨️ **Pagination**: Flexible inline keyboard pagination with search capability
- 🔧 **Template System**: Simple template system for message text formatting
## Installation
```bash
pip install gram-tools
```
## Components
### Message Packer
Pack and unpack Telegram messages with all their content for easy storage and reuse.
```python
from gram_tools.packer import pack_message, unpack_message, send_packed_message, answer_packed_message
# Pack a message
packed = await pack_message(message)
# Send packed message
await send_packed_message(bot, chat_id, packed)
# Answer with packed message
await answer_packed_message(message, packed)
```
### Whole example
```python
import asyncio
from aiogram import Bot, Dispatcher, Router, F
from aiogram.types import Message
from aiogram.filters import Command
from gram_tools.packer import (
pack_message,
send_packed_message,
)
API_TOKEN = "BOT TOKEN"
bot = Bot(token=API_TOKEN)
dp = Dispatcher()
router = Router()
# In-memory storage for packed messages
packed_messages = {}
@router.message(Command("save"))
async def save_message(message: Message):
if not message.reply_to_message:
await message.answer("Please reply to the message you want to save.")
return
# Pack the replied message
packed = await pack_message(message.reply_to_message)
# Store the packed message using user's chat ID
packed_messages[message.from_user.id] = packed
await message.answer("Message saved!")
@router.message(Command("send"))
async def send_saved_message(message: Message):
packed = packed_messages.get(message.from_user.id)
if not packed:
await message.answer("No message saved. Use /save to save a message.")
return
# Send the packed message back to the user
await send_packed_message(bot, message.chat.id, packed)
async def main():
dp.include_router(router)
await dp.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())
```
### Asset Management
Efficiently handle media files with automatic file_id caching.
```python
from gram_tools.assets import answer_with_asset, send_with_asset
# Send media file
await send_with_asset(bot, chat_id, "path/to/file.jpg", caption="My photo") # also u can without caption, and also you can use reply_markup and more
# Answer with media file
await answer_with_asset(message, "path/to/file.mp4")
```
### Whole example how to use
```python
import asyncio
from aiogram import Bot, Dispatcher, Router, F
from aiogram.types import Message
from aiogram.filters import Command
from gram_tools.assets import send_with_asset, answer_with_asset
API_TOKEN = "BOT TOKEN"
bot = Bot(token=API_TOKEN)
dp = Dispatcher()
router = Router()
@router.message(Command("photo"))
async def send_photo(message: Message):
await send_with_asset(
bot,
chat_id=message.chat.id,
file_path=r"D:\Dokumenti\Pictures\photo_2024-08-11_18-25-01.jpg",
caption="Here is a photo!"
)
@router.message(Command("video"))
async def send_video(message: Message):
await answer_with_asset(
message=message,
file_path=r"D:\Dokumenti\Documents\Untitled.mp4",
caption="Here is a video!"
)
async def main():
dp.include_router(router)
await dp.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())
```
Supported media types:
- Photos: jpg, jpeg, png, svg, webp, bmp, jfif, heic, heif
- Videos: mp4, mov, avi, mkv, m4v, 3gp
- Audio: mp3, wav, flac, m4a, ogg, aac
- Voice: ogg, oga
- Animations: gif
- Any other file type as document
### CRUD Operations
Generic CRUD operations for SQLAlchemy models.
```python
from gram_tools.crud import get_crud
from sqlalchemy import Column, Integer, String
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
telegram_id = Column(Integer, unique=True, index=True)
name = Column(String)
user_crud = get_crud(User)
# Add new instance
await user_crud.add(session, user)
# Get instance
user = await user_crud.get(session, id=1)
# Get all instances
active_users = await user_crud.get_all(session, status="active")
# Get count of instances
active_users_count = await user_crud.get_all_count(session, status="active")
# Update instance
await user_crud.update(session, user, name="New Name")
# Delete instance
await user_crud.delete(session, user)
```
### Pagination Keyboards
Create paginated inline keyboards with optional search functionality.
```python
from gram_tools.keyboards import InlineBuilder, SearchButton
# Create pagination with search
builder = InlineBuilder(
per_page=5,
layout=1,
search_button=SearchButton("🔍")
)
items = ["Item 1", "Item 2", "Item 3", ...]
keyboard = builder.get_paginated(items, page=1)
# With search
keyboard = builder.get_paginated(items, page=1, search_term="search query")
```
Customization options:
- `per_page`: Items per page
- `layout`: Number of buttons per row
- `next_button_text`: Next page button text
- `prev_button_text`: Previous page button text
- `page_callback_prefix`: Optional callback prefix of core paginator and item selection
- `ignore_callback_prefix`: Optional callback prefox on click ignore items
- `not_exist_page`: Text for disabled navigation buttons
- `search_button`: Optional search button configuration
### Whole example:
```python
import asyncio
from aiogram import Bot, Dispatcher, Router, F
from aiogram.types import CallbackQuery, Message
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import StatesGroup, State
from gram_tools.keyboards import (
InlineBuilder,
SearchButton
)
API_TOKEN = "7350010616:AAHQXoWzmMuffBkv5mPAI2-cBziZBkvoTI8"
bot = Bot(token=API_TOKEN)
dp = Dispatcher()
router = Router()
items = [f"Item {i}" for i in range(1, 21)]
search_button = SearchButton(button_text="🔍 Search")
inline_builder = InlineBuilder(per_page=5, search_button=search_button)
class Form(StatesGroup):
waiting_for_search_term = State()
@router.message(Command("start"))
async def show_inline_keyboard(message: Message, state: FSMContext):
await state.clear()
keyboard = inline_builder.get_paginated(items, page=1)
await message.answer("Select an item:", reply_markup=keyboard)
@router.callback_query(inline_builder.page_callback.filter())
async def handle_callback(callback: CallbackQuery, callback_data: inline_builder.page_callback, state: FSMContext):
action = callback_data.action
value = callback_data.value
data = await state.get_data()
search_term = data.get('search_term')
if action in ("next", "prev"):
page = value
keyboard = inline_builder.get_paginated(items, page=page, search_term=search_term)
await callback.message.edit_reply_markup(reply_markup=keyboard)
await callback.answer()
elif action == "sel":
item_index = value
if search_term:
filtered_items = [item for item in items if search_term.lower() in str(item).lower()]
else:
filtered_items = items
if 0 <= item_index < len(filtered_items):
selected_item = filtered_items[item_index]
await callback.message.answer(f"You selected: {selected_item}")
else:
await callback.message.answer("Element not found.")
await callback.answer()
@router.callback_query(search_button.search_callback.filter())
async def handle_search_callback(callback: CallbackQuery, state: FSMContext):
print(callback.data)
await state.update_data(search_term=None)
await callback.message.answer("Type text to search:")
await state.set_state(Form.waiting_for_search_term)
await callback.answer()
@router.message(Form.waiting_for_search_term)
async def perform_search(message: Message, state: FSMContext):
search_term = message.text
await state.update_data(search_term=search_term)
await state.set_state(None)
keyboard = inline_builder.get_paginated(items, page=1, search_term=search_term)
if keyboard.inline_keyboard:
await message.answer("Results:", reply_markup=keyboard)
else:
await message.answer("Nothing found.")
@router.callback_query(inline_builder.ignore_callback.filter())
async def handle_ignore_callback(callback: CallbackQuery):
await callback.answer()
async def main():
dp.include_router(router)
await dp.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())
```
### Template System
Simple template system for message text formatting.
```python
from gram_tools.messages import T
template = T("Hello, ${name}!")
text = template.text(name="User")
```
### Whole example
```python
import asyncio
from aiogram import Bot, Dispatcher, Router, F
from aiogram.types import Message
from aiogram.filters import Command
from gram_tools.messages import T
API_TOKEN = "BOT TOKEN"
bot = Bot(token=API_TOKEN)
dp = Dispatcher()
router = Router()
# Define a template
welcome_template = T("Hello, ${name}! Welcome to our bot.")
@router.message(Command("start"))
async def send_welcome(message: Message):
# Render the template with the user's name
text = welcome_template.text(name=message.from_user.full_name)
await message.answer(text)
@router.message(Command("info"))
async def send_info(message: Message):
info_template = T(
"User Info:\n"
"- ID: ${id}\n"
"- Username: ${username}\n"
"- First Name: ${first_name}\n"
"- Last Name: ${last_name}"
)
text = info_template.text(
id=message.from_user.id,
username=message.from_user.username or "N/A",
first_name=message.from_user.first_name or "N/A",
last_name=message.from_user.last_name or "N/A"
)
await message.answer(text)
async def main():
dp.include_router(router)
await dp.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())
```
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
Raw data
{
"_id": null,
"home_page": "https://github.com/DedInc/gram-tools",
"name": "gram-tools",
"maintainer": null,
"docs_url": null,
"requires_python": ">=3.6",
"maintainer_email": null,
"keywords": null,
"author": "Maehdakvan",
"author_email": "visitanimation@google.com",
"download_url": "https://files.pythonhosted.org/packages/66/5b/a3b1ab3a02cfa87d5b2300b423797c64ed55805a9f50c93a346be8892ae3/gram_tools-1.0.2.tar.gz",
"platform": null,
"description": "# gram_tools\r\n\r\nUtilities for streamlined [aiogram 3](https://github.com/aiogram/aiogram) bot development.\r\n\r\n## Features\r\n\r\n- \ud83d\udce6 **Message Packing**: Serialize and deserialize Telegram messages with support for all media types\r\n- \ud83d\uddc4\ufe0f **Asset Management**: Efficient handling of media files with caching and file_id reuse\r\n- \ud83d\udcda **CRUD Operations**: Generic async CRUD operations for SQLAlchemy models\r\n- \u2328\ufe0f **Pagination**: Flexible inline keyboard pagination with search capability\r\n- \ud83d\udd27 **Template System**: Simple template system for message text formatting\r\n\r\n## Installation\r\n\r\n```bash\r\npip install gram-tools\r\n```\r\n\r\n## Components\r\n\r\n### Message Packer\r\n\r\nPack and unpack Telegram messages with all their content for easy storage and reuse.\r\n\r\n```python\r\nfrom gram_tools.packer import pack_message, unpack_message, send_packed_message, answer_packed_message\r\n\r\n# Pack a message\r\npacked = await pack_message(message)\r\n\r\n# Send packed message\r\nawait send_packed_message(bot, chat_id, packed)\r\n\r\n# Answer with packed message\r\nawait answer_packed_message(message, packed)\r\n```\r\n\r\n### Whole example\r\n\r\n```python\r\nimport asyncio\r\nfrom aiogram import Bot, Dispatcher, Router, F\r\nfrom aiogram.types import Message\r\nfrom aiogram.filters import Command\r\n\r\nfrom gram_tools.packer import (\r\n pack_message,\r\n send_packed_message,\r\n)\r\n\r\nAPI_TOKEN = \"BOT TOKEN\"\r\n\r\nbot = Bot(token=API_TOKEN)\r\ndp = Dispatcher()\r\nrouter = Router()\r\n\r\n# In-memory storage for packed messages\r\npacked_messages = {}\r\n\r\n@router.message(Command(\"save\"))\r\nasync def save_message(message: Message):\r\n if not message.reply_to_message:\r\n await message.answer(\"Please reply to the message you want to save.\")\r\n return\r\n\r\n # Pack the replied message\r\n packed = await pack_message(message.reply_to_message)\r\n\r\n # Store the packed message using user's chat ID\r\n packed_messages[message.from_user.id] = packed\r\n await message.answer(\"Message saved!\")\r\n\r\n@router.message(Command(\"send\"))\r\nasync def send_saved_message(message: Message):\r\n packed = packed_messages.get(message.from_user.id)\r\n if not packed:\r\n await message.answer(\"No message saved. Use /save to save a message.\")\r\n return\r\n\r\n # Send the packed message back to the user\r\n await send_packed_message(bot, message.chat.id, packed)\r\n\r\nasync def main():\r\n dp.include_router(router)\r\n await dp.start_polling(bot)\r\n\r\nif __name__ == \"__main__\":\r\n asyncio.run(main())\r\n```\r\n\r\n### Asset Management\r\n\r\nEfficiently handle media files with automatic file_id caching.\r\n\r\n```python\r\nfrom gram_tools.assets import answer_with_asset, send_with_asset\r\n\r\n# Send media file\r\nawait send_with_asset(bot, chat_id, \"path/to/file.jpg\", caption=\"My photo\") # also u can without caption, and also you can use reply_markup and more\r\n\r\n# Answer with media file\r\nawait answer_with_asset(message, \"path/to/file.mp4\")\r\n```\r\n\r\n### Whole example how to use\r\n\r\n```python\r\nimport asyncio\r\nfrom aiogram import Bot, Dispatcher, Router, F\r\nfrom aiogram.types import Message\r\nfrom aiogram.filters import Command\r\n\r\nfrom gram_tools.assets import send_with_asset, answer_with_asset\r\n\r\nAPI_TOKEN = \"BOT TOKEN\"\r\n\r\nbot = Bot(token=API_TOKEN)\r\ndp = Dispatcher()\r\nrouter = Router()\r\n\r\n@router.message(Command(\"photo\"))\r\nasync def send_photo(message: Message):\r\n await send_with_asset(\r\n bot,\r\n chat_id=message.chat.id,\r\n file_path=r\"D:\\Dokumenti\\Pictures\\photo_2024-08-11_18-25-01.jpg\",\r\n caption=\"Here is a photo!\"\r\n )\r\n\r\n@router.message(Command(\"video\"))\r\nasync def send_video(message: Message):\r\n await answer_with_asset(\r\n message=message,\r\n file_path=r\"D:\\Dokumenti\\Documents\\Untitled.mp4\",\r\n caption=\"Here is a video!\"\r\n )\r\n\r\nasync def main():\r\n dp.include_router(router)\r\n await dp.start_polling(bot)\r\n\r\nif __name__ == \"__main__\":\r\n asyncio.run(main())\r\n\r\n```\r\n\r\nSupported media types:\r\n- Photos: jpg, jpeg, png, svg, webp, bmp, jfif, heic, heif\r\n- Videos: mp4, mov, avi, mkv, m4v, 3gp\r\n- Audio: mp3, wav, flac, m4a, ogg, aac\r\n- Voice: ogg, oga\r\n- Animations: gif\r\n- Any other file type as document\r\n\r\n### CRUD Operations\r\n\r\nGeneric CRUD operations for SQLAlchemy models.\r\n\r\n```python\r\nfrom gram_tools.crud import get_crud\r\nfrom sqlalchemy import Column, Integer, String\r\n\r\nclass User(Base):\r\n __tablename__ = \"users\"\r\n\r\n id = Column(Integer, primary_key=True, index=True)\r\n telegram_id = Column(Integer, unique=True, index=True)\r\n name = Column(String)\r\n\r\n\r\nuser_crud = get_crud(User)\r\n\r\n# Add new instance\r\nawait user_crud.add(session, user)\r\n\r\n# Get instance\r\nuser = await user_crud.get(session, id=1)\r\n\r\n# Get all instances\r\nactive_users = await user_crud.get_all(session, status=\"active\")\r\n\r\n# Get count of instances\r\nactive_users_count = await user_crud.get_all_count(session, status=\"active\")\r\n\r\n# Update instance\r\nawait user_crud.update(session, user, name=\"New Name\")\r\n\r\n# Delete instance\r\nawait user_crud.delete(session, user)\r\n```\r\n\r\n### Pagination Keyboards\r\n\r\nCreate paginated inline keyboards with optional search functionality.\r\n\r\n```python\r\nfrom gram_tools.keyboards import InlineBuilder, SearchButton\r\n\r\n# Create pagination with search\r\nbuilder = InlineBuilder(\r\n per_page=5,\r\n layout=1,\r\n search_button=SearchButton(\"\ud83d\udd0d\")\r\n)\r\n\r\nitems = [\"Item 1\", \"Item 2\", \"Item 3\", ...]\r\nkeyboard = builder.get_paginated(items, page=1)\r\n\r\n# With search\r\nkeyboard = builder.get_paginated(items, page=1, search_term=\"search query\")\r\n```\r\n\r\nCustomization options:\r\n- `per_page`: Items per page\r\n- `layout`: Number of buttons per row\r\n- `next_button_text`: Next page button text\r\n- `prev_button_text`: Previous page button text\r\n- `page_callback_prefix`: Optional callback prefix of core paginator and item selection\r\n- `ignore_callback_prefix`: Optional callback prefox on click ignore items\r\n- `not_exist_page`: Text for disabled navigation buttons\r\n- `search_button`: Optional search button configuration\r\n\r\n### Whole example:\r\n\r\n```python\r\nimport asyncio\r\nfrom aiogram import Bot, Dispatcher, Router, F\r\nfrom aiogram.types import CallbackQuery, Message\r\nfrom aiogram.filters import Command\r\nfrom aiogram.fsm.context import FSMContext\r\nfrom aiogram.fsm.state import StatesGroup, State\r\n\r\nfrom gram_tools.keyboards import (\r\n InlineBuilder,\r\n SearchButton\r\n)\r\n\r\nAPI_TOKEN = \"7350010616:AAHQXoWzmMuffBkv5mPAI2-cBziZBkvoTI8\"\r\n\r\nbot = Bot(token=API_TOKEN)\r\ndp = Dispatcher()\r\nrouter = Router()\r\n\r\nitems = [f\"Item {i}\" for i in range(1, 21)]\r\nsearch_button = SearchButton(button_text=\"\ud83d\udd0d Search\")\r\ninline_builder = InlineBuilder(per_page=5, search_button=search_button)\r\n\r\nclass Form(StatesGroup):\r\n waiting_for_search_term = State()\r\n\r\n@router.message(Command(\"start\"))\r\nasync def show_inline_keyboard(message: Message, state: FSMContext):\r\n await state.clear()\r\n keyboard = inline_builder.get_paginated(items, page=1)\r\n await message.answer(\"Select an item:\", reply_markup=keyboard)\r\n\r\n@router.callback_query(inline_builder.page_callback.filter())\r\nasync def handle_callback(callback: CallbackQuery, callback_data: inline_builder.page_callback, state: FSMContext):\r\n action = callback_data.action\r\n value = callback_data.value\r\n\r\n data = await state.get_data()\r\n search_term = data.get('search_term')\r\n\r\n if action in (\"next\", \"prev\"):\r\n page = value\r\n keyboard = inline_builder.get_paginated(items, page=page, search_term=search_term)\r\n await callback.message.edit_reply_markup(reply_markup=keyboard)\r\n await callback.answer()\r\n elif action == \"sel\":\r\n item_index = value\r\n if search_term:\r\n filtered_items = [item for item in items if search_term.lower() in str(item).lower()]\r\n else:\r\n filtered_items = items\r\n\r\n if 0 <= item_index < len(filtered_items):\r\n selected_item = filtered_items[item_index]\r\n await callback.message.answer(f\"You selected: {selected_item}\")\r\n else:\r\n await callback.message.answer(\"Element not found.\")\r\n await callback.answer()\r\n\r\n@router.callback_query(search_button.search_callback.filter())\r\nasync def handle_search_callback(callback: CallbackQuery, state: FSMContext):\r\n print(callback.data)\r\n await state.update_data(search_term=None)\r\n await callback.message.answer(\"Type text to search:\")\r\n await state.set_state(Form.waiting_for_search_term)\r\n await callback.answer()\r\n\r\n@router.message(Form.waiting_for_search_term)\r\nasync def perform_search(message: Message, state: FSMContext):\r\n search_term = message.text\r\n await state.update_data(search_term=search_term)\r\n await state.set_state(None)\r\n\r\n keyboard = inline_builder.get_paginated(items, page=1, search_term=search_term)\r\n\r\n if keyboard.inline_keyboard:\r\n await message.answer(\"Results:\", reply_markup=keyboard)\r\n else:\r\n await message.answer(\"Nothing found.\")\r\n\r\n@router.callback_query(inline_builder.ignore_callback.filter())\r\nasync def handle_ignore_callback(callback: CallbackQuery):\r\n await callback.answer()\r\n\r\nasync def main():\r\n dp.include_router(router)\r\n await dp.start_polling(bot)\r\n\r\nif __name__ == \"__main__\":\r\n asyncio.run(main())\r\n```\r\n\r\n### Template System\r\n\r\nSimple template system for message text formatting.\r\n\r\n```python\r\nfrom gram_tools.messages import T\r\n\r\ntemplate = T(\"Hello, ${name}!\")\r\ntext = template.text(name=\"User\")\r\n```\r\n\r\n### Whole example\r\n\r\n```python\r\nimport asyncio\r\nfrom aiogram import Bot, Dispatcher, Router, F\r\nfrom aiogram.types import Message\r\nfrom aiogram.filters import Command\r\n\r\nfrom gram_tools.messages import T\r\n\r\nAPI_TOKEN = \"BOT TOKEN\"\r\n\r\nbot = Bot(token=API_TOKEN)\r\ndp = Dispatcher()\r\nrouter = Router()\r\n\r\n# Define a template\r\nwelcome_template = T(\"Hello, ${name}! Welcome to our bot.\")\r\n\r\n@router.message(Command(\"start\"))\r\nasync def send_welcome(message: Message):\r\n # Render the template with the user's name\r\n text = welcome_template.text(name=message.from_user.full_name)\r\n await message.answer(text)\r\n\r\n@router.message(Command(\"info\"))\r\nasync def send_info(message: Message):\r\n info_template = T(\r\n \"User Info:\\n\"\r\n \"- ID: ${id}\\n\"\r\n \"- Username: ${username}\\n\"\r\n \"- First Name: ${first_name}\\n\"\r\n \"- Last Name: ${last_name}\"\r\n )\r\n text = info_template.text(\r\n id=message.from_user.id,\r\n username=message.from_user.username or \"N/A\",\r\n first_name=message.from_user.first_name or \"N/A\",\r\n last_name=message.from_user.last_name or \"N/A\"\r\n )\r\n await message.answer(text)\r\n\r\nasync def main():\r\n dp.include_router(router)\r\n await dp.start_polling(bot)\r\n\r\nif __name__ == \"__main__\":\r\n asyncio.run(main())\r\n```\r\n\r\n## Contributing\r\n\r\nContributions are welcome! Please feel free to submit a Pull Request.\r\n",
"bugtrack_url": null,
"license": null,
"summary": "Utilities for streamlined aiogram bot development.",
"version": "1.0.2",
"project_urls": {
"Bug Tracker": "https://github.com/DedInc/gram-tools/issues",
"Homepage": "https://github.com/DedInc/gram-tools"
},
"split_keywords": [],
"urls": [
{
"comment_text": "",
"digests": {
"blake2b_256": "3764743fdf6610cb59061caaa61358538d3f860acd62339a5a81586999558f20",
"md5": "5183e50ddf326bce6ffaac0f9a9a8875",
"sha256": "5806e1aef8e204f7eae815cc3e043b4642b83cb4856df43da8e073537a82e2bc"
},
"downloads": -1,
"filename": "gram_tools-1.0.2-py3-none-any.whl",
"has_sig": false,
"md5_digest": "5183e50ddf326bce6ffaac0f9a9a8875",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": ">=3.6",
"size": 9424,
"upload_time": "2024-11-09T11:24:40",
"upload_time_iso_8601": "2024-11-09T11:24:40.258249Z",
"url": "https://files.pythonhosted.org/packages/37/64/743fdf6610cb59061caaa61358538d3f860acd62339a5a81586999558f20/gram_tools-1.0.2-py3-none-any.whl",
"yanked": false,
"yanked_reason": null
},
{
"comment_text": "",
"digests": {
"blake2b_256": "665ba3b1ab3a02cfa87d5b2300b423797c64ed55805a9f50c93a346be8892ae3",
"md5": "5c8e46ed1b43b6aebc03e89dc71e0502",
"sha256": "6f598f583111da350836dc3df9cfdaf2eba5cd9646536d3ec4696c7414a41e62"
},
"downloads": -1,
"filename": "gram_tools-1.0.2.tar.gz",
"has_sig": false,
"md5_digest": "5c8e46ed1b43b6aebc03e89dc71e0502",
"packagetype": "sdist",
"python_version": "source",
"requires_python": ">=3.6",
"size": 11431,
"upload_time": "2024-11-09T11:24:44",
"upload_time_iso_8601": "2024-11-09T11:24:44.309181Z",
"url": "https://files.pythonhosted.org/packages/66/5b/a3b1ab3a02cfa87d5b2300b423797c64ed55805a9f50c93a346be8892ae3/gram_tools-1.0.2.tar.gz",
"yanked": false,
"yanked_reason": null
}
],
"upload_time": "2024-11-09 11:24:44",
"github": true,
"gitlab": false,
"bitbucket": false,
"codeberg": false,
"github_user": "DedInc",
"github_project": "gram-tools",
"travis_ci": false,
"coveralls": false,
"github_actions": false,
"lcname": "gram-tools"
}