From 344d885acf6c371445c5fcabf4d0c434313c6079 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 12 Mar 2025 22:13:09 +0900 Subject: [PATCH 01/60] Update files for SOLAPI Python 5.0.0 base --- .gitignore | 42 +++- .ruff.toml | 31 +++ README.md | 14 +- examples/sms/sms.py | 1 + poetry.lock | 203 ++++++++++++++++++ pyproject.toml | 22 ++ src/examples/modules/README.md | 53 ----- src/examples/modules/group/README.md | 28 --- .../modules/group/add_group_message.py | 29 --- .../group/add_group_message_with_image.py | 28 --- .../modules/group/cancel_reservation_group.py | 11 - src/examples/modules/group/create_group.py | 6 - .../modules/group/delete_group_info.py | 8 - .../modules/group/delete_group_message.py | 17 -- src/examples/modules/group/get_group_info.py | 8 - src/examples/modules/group/get_group_list.py | 6 - .../modules/group/get_group_message.py | 8 - .../modules/group/send_group_message.py | 8 - .../modules/group/set_reservation_group.py | 24 --- .../modules/kakaotalk/send_alimtalk.py | 73 ------- .../modules/kakaotalk/send_chingutalk.py | 72 ------- src/examples/modules/message_list.py | 57 ----- src/examples/modules/naver/__init__.py | 0 src/examples/modules/naver/send_naver.py | 58 ----- src/examples/modules/rcs/__init__.py | 0 src/examples/modules/rcs/images/sample1.png | Bin 4836 -> 0 bytes src/examples/modules/rcs/images/sample2.png | Bin 4841 -> 0 bytes src/examples/modules/rcs/images/sample3.png | Bin 4817 -> 0 bytes src/examples/modules/rcs/images/sample4.png | Bin 4740 -> 0 bytes src/examples/modules/rcs/images/sample5.png | Bin 4804 -> 0 bytes src/examples/modules/rcs/images/sample6.png | Bin 4733 -> 0 bytes src/examples/modules/rcs/send_rcs_lms.py | 43 ---- src/examples/modules/rcs/send_rcs_mms.py | 48 ----- src/examples/modules/rcs/send_rcs_mms_m3.py | 59 ----- src/examples/modules/rcs/send_rcs_mms_m6.py | 86 -------- src/examples/modules/rcs/send_rcs_mms_s3.py | 59 ----- src/examples/modules/rcs/send_rcs_mms_s6.py | 86 -------- src/examples/modules/rcs/send_rcs_sms.py | 39 ---- src/examples/modules/rcs/send_rcs_tpl.py | 41 ---- src/examples/modules/senderid/README.md | 18 -- src/examples/modules/senderid/__init__.py | 0 .../modules/senderid/create_number.py | 10 - .../modules/senderid/get_active_numbers.py | 9 - .../modules/senderid/request_voicecall.py | 28 --- src/examples/modules/senderid/verify_token.py | 33 --- src/examples/modules/sms/__init__.py | 0 src/examples/modules/sms/allow_duplicates.py | 25 --- src/examples/modules/sms/send_global_sms.py | 17 -- src/examples/modules/sms/send_lms.py | 41 ---- src/examples/modules/sms/send_mms.py | 36 ---- src/examples/modules/sms/send_sms.py | 40 ---- src/examples/modules/storage/__init__.py | 0 src/examples/modules/storage/delete_image.py | 12 -- .../modules/storage/get_image_info.py | 12 -- .../modules/storage/get_image_list.py | 10 - src/examples/modules/storage/testImage.jpg | Bin 12664 -> 0 bytes src/examples/modules/storage/upload_image.py | 10 - src/examples/modules/testImage.jpg | Bin 61963 -> 0 bytes src/examples/scripts/README.md | 17 -- src/examples/scripts/get_balances.py | 61 ------ src/examples/scripts/get_messages.py | 105 --------- src/examples/scripts/send_messages.py | 120 ----------- src/examples/scripts/set_scheduled_message.py | 151 ------------- src/lib/__init__.py | 6 - src/lib/auth.py | 30 --- src/lib/config-dist.ini | 9 - src/lib/config.py | 20 -- src/lib/message.py | 48 ----- src/lib/storage.py | 37 ---- src/{ => solapi}/__init__.py | 0 src/{examples => solapi/lib}/__init__.py | 0 src/solapi/lib/authenticator.py | 40 ++++ src/solapi/lib/fetcher.py | 16 ++ src/solapi/lib/string_date_transfer.py | 99 +++++++++ .../modules => solapi/requests}/__init__.py | 0 src/solapi/requests/message_request.py | 15 ++ src/solapi/requests/request_confiog.py | 18 ++ .../group/__init__.py => solapi/solapi.py} | 0 .../modules/kakaotalk => tests}/__init__.py | 0 79 files changed, 484 insertions(+), 1877 deletions(-) create mode 100644 .ruff.toml create mode 100644 examples/sms/sms.py create mode 100644 poetry.lock create mode 100644 pyproject.toml delete mode 100644 src/examples/modules/README.md delete mode 100644 src/examples/modules/group/README.md delete mode 100644 src/examples/modules/group/add_group_message.py delete mode 100644 src/examples/modules/group/add_group_message_with_image.py delete mode 100644 src/examples/modules/group/cancel_reservation_group.py delete mode 100644 src/examples/modules/group/create_group.py delete mode 100644 src/examples/modules/group/delete_group_info.py delete mode 100644 src/examples/modules/group/delete_group_message.py delete mode 100644 src/examples/modules/group/get_group_info.py delete mode 100644 src/examples/modules/group/get_group_list.py delete mode 100644 src/examples/modules/group/get_group_message.py delete mode 100644 src/examples/modules/group/send_group_message.py delete mode 100644 src/examples/modules/group/set_reservation_group.py delete mode 100644 src/examples/modules/kakaotalk/send_alimtalk.py delete mode 100644 src/examples/modules/kakaotalk/send_chingutalk.py delete mode 100644 src/examples/modules/message_list.py delete mode 100644 src/examples/modules/naver/__init__.py delete mode 100644 src/examples/modules/naver/send_naver.py delete mode 100644 src/examples/modules/rcs/__init__.py delete mode 100644 src/examples/modules/rcs/images/sample1.png delete mode 100644 src/examples/modules/rcs/images/sample2.png delete mode 100644 src/examples/modules/rcs/images/sample3.png delete mode 100644 src/examples/modules/rcs/images/sample4.png delete mode 100644 src/examples/modules/rcs/images/sample5.png delete mode 100644 src/examples/modules/rcs/images/sample6.png delete mode 100644 src/examples/modules/rcs/send_rcs_lms.py delete mode 100644 src/examples/modules/rcs/send_rcs_mms.py delete mode 100644 src/examples/modules/rcs/send_rcs_mms_m3.py delete mode 100644 src/examples/modules/rcs/send_rcs_mms_m6.py delete mode 100644 src/examples/modules/rcs/send_rcs_mms_s3.py delete mode 100644 src/examples/modules/rcs/send_rcs_mms_s6.py delete mode 100644 src/examples/modules/rcs/send_rcs_sms.py delete mode 100644 src/examples/modules/rcs/send_rcs_tpl.py delete mode 100644 src/examples/modules/senderid/README.md delete mode 100644 src/examples/modules/senderid/__init__.py delete mode 100644 src/examples/modules/senderid/create_number.py delete mode 100644 src/examples/modules/senderid/get_active_numbers.py delete mode 100644 src/examples/modules/senderid/request_voicecall.py delete mode 100644 src/examples/modules/senderid/verify_token.py delete mode 100644 src/examples/modules/sms/__init__.py delete mode 100644 src/examples/modules/sms/allow_duplicates.py delete mode 100644 src/examples/modules/sms/send_global_sms.py delete mode 100644 src/examples/modules/sms/send_lms.py delete mode 100644 src/examples/modules/sms/send_mms.py delete mode 100644 src/examples/modules/sms/send_sms.py delete mode 100644 src/examples/modules/storage/__init__.py delete mode 100644 src/examples/modules/storage/delete_image.py delete mode 100644 src/examples/modules/storage/get_image_info.py delete mode 100644 src/examples/modules/storage/get_image_list.py delete mode 100644 src/examples/modules/storage/testImage.jpg delete mode 100644 src/examples/modules/storage/upload_image.py delete mode 100644 src/examples/modules/testImage.jpg delete mode 100644 src/examples/scripts/README.md delete mode 100644 src/examples/scripts/get_balances.py delete mode 100644 src/examples/scripts/get_messages.py delete mode 100644 src/examples/scripts/send_messages.py delete mode 100644 src/examples/scripts/set_scheduled_message.py delete mode 100644 src/lib/__init__.py delete mode 100644 src/lib/auth.py delete mode 100644 src/lib/config-dist.ini delete mode 100644 src/lib/config.py delete mode 100644 src/lib/message.py delete mode 100644 src/lib/storage.py rename src/{ => solapi}/__init__.py (100%) rename src/{examples => solapi/lib}/__init__.py (100%) create mode 100644 src/solapi/lib/authenticator.py create mode 100644 src/solapi/lib/fetcher.py create mode 100644 src/solapi/lib/string_date_transfer.py rename src/{examples/modules => solapi/requests}/__init__.py (100%) create mode 100644 src/solapi/requests/message_request.py create mode 100644 src/solapi/requests/request_confiog.py rename src/{examples/modules/group/__init__.py => solapi/solapi.py} (100%) rename {src/examples/modules/kakaotalk => tests}/__init__.py (100%) diff --git a/.gitignore b/.gitignore index 2dfd575..08016bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,38 @@ -.idea/ -venv/ +# Python __pycache__/ -*.pyc -config.ini -*.real.py +*.py[cod] +*$py.class + +# Poetry +.venv/ +dist/ + +# IDE +.vscode/ +.idea/ + +# 환경 변수 +.env + +# 로그 파일 +*.log + +# 운영체제 관련 파일 +.DS_Store +Thumbs.db + +# 테스트 관련 +.pytest_cache/ +.coverage + +# 빌드 관련 +build/ +*.egg-info/ + +# 기타 +*.bak +*.swp +*.swo + +# ruff +.ruff_cache/ diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..1ebcbfb --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,31 @@ +# 최대 줄 길이 설정 +line-length = 88 +# 들여쓰기 너비 +indent-width = 4 +# Python 버전 타겟 설정 +target-version = "py39" + +[lint] +# 기본적으로 Pyflakes(F)와 pycodestyle(E)의 일부 규칙 활성화 +select = ["E4", "E7", "E9", "F"] +# 추가로 많이 사용되는 규칙들 +extend-select = [ + "B", # flake8-bugbear + "I", # isort + "UP", # pyupgrade + "N", # pep8-naming +] +# 무시할 규칙 +ignore = [] +# 언더스코어로 시작하는 변수는 미사용 변수로 간주하지 않음 +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +[format] +# 문자열에 큰따옴표 사용 +quote-style = "double" +# 들여쓰기에 스페이스 사용 +indent-style = "space" +# 마법의 후행 쉼표 존중 +skip-magic-trailing-comma = false +# 자동으로 적절한 줄 끝 감지 +line-ending = "auto" diff --git a/README.md b/README.md index efcb067..150a2e8 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,4 @@ # Solapi SDK for Python -[![Python 3.10.2](https://img.shields.io/badge/python-3.10.2-blue.svg)](https://www.python.org/downloads/release/python-3102/) -![Python Supported version](https://img.shields.io/badge/python-%3E%3D3.7-orange) - -### SOLAPI SDK 이용을 위해 아래 라이브러리 설치를 필요로 합니다. - -- requests -- configparser - -### 예제 유형 별 참고사항 - -- 일반적인 사용예제는 examples/modules 폴더를 참고 해주세요. -- python 인터프리터를 통해 한 파일 안에서 구동되는 예제들은 examples/scripts 폴더를 참고해주세요. +[![Python 3.10.2](https://img.shields.io/badge/python-3.13.2-blue.svg)](https://www.python.org/downloads) +![Python Supported version](https://img.shields.io/badge/python-%3E%3D3.11-orange) \ No newline at end of file diff --git a/examples/sms/sms.py b/examples/sms/sms.py new file mode 100644 index 0000000..66632ea --- /dev/null +++ b/examples/sms/sms.py @@ -0,0 +1 @@ +print("test") \ No newline at end of file diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..6858389 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,203 @@ +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. + +[[package]] +name = "certifi" +version = "2025.1.31" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, + {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, + {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, + {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, +] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "ruff" +version = "0.9.10" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.9.10-py3-none-linux_armv6l.whl", hash = "sha256:eb4d25532cfd9fe461acc83498361ec2e2252795b4f40b17e80692814329e42d"}, + {file = "ruff-0.9.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:188a6638dab1aa9bb6228a7302387b2c9954e455fb25d6b4470cb0641d16759d"}, + {file = "ruff-0.9.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5284dcac6b9dbc2fcb71fdfc26a217b2ca4ede6ccd57476f52a587451ebe450d"}, + {file = "ruff-0.9.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47678f39fa2a3da62724851107f438c8229a3470f533894b5568a39b40029c0c"}, + {file = "ruff-0.9.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:99713a6e2766b7a17147b309e8c915b32b07a25c9efd12ada79f217c9c778b3e"}, + {file = "ruff-0.9.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:524ee184d92f7c7304aa568e2db20f50c32d1d0caa235d8ddf10497566ea1a12"}, + {file = "ruff-0.9.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:df92aeac30af821f9acf819fc01b4afc3dfb829d2782884f8739fb52a8119a16"}, + {file = "ruff-0.9.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de42e4edc296f520bb84954eb992a07a0ec5a02fecb834498415908469854a52"}, + {file = "ruff-0.9.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d257f95b65806104b6b1ffca0ea53f4ef98454036df65b1eda3693534813ecd1"}, + {file = "ruff-0.9.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b60dec7201c0b10d6d11be00e8f2dbb6f40ef1828ee75ed739923799513db24c"}, + {file = "ruff-0.9.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d838b60007da7a39c046fcdd317293d10b845001f38bcb55ba766c3875b01e43"}, + {file = "ruff-0.9.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ccaf903108b899beb8e09a63ffae5869057ab649c1e9231c05ae354ebc62066c"}, + {file = "ruff-0.9.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f9567d135265d46e59d62dc60c0bfad10e9a6822e231f5b24032dba5a55be6b5"}, + {file = "ruff-0.9.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5f202f0d93738c28a89f8ed9eaba01b7be339e5d8d642c994347eaa81c6d75b8"}, + {file = "ruff-0.9.10-py3-none-win32.whl", hash = "sha256:bfb834e87c916521ce46b1788fbb8484966e5113c02df216680102e9eb960029"}, + {file = "ruff-0.9.10-py3-none-win_amd64.whl", hash = "sha256:f2160eeef3031bf4b17df74e307d4c5fb689a6f3a26a2de3f7ef4044e3c484f1"}, + {file = "ruff-0.9.10-py3-none-win_arm64.whl", hash = "sha256:5fd804c0327a5e5ea26615550e706942f348b197d5475ff34c19733aee4b2e69"}, + {file = "ruff-0.9.10.tar.gz", hash = "sha256:9bacb735d7bada9cfb0f2c227d3658fc443d90a727b47f206fb33f52f3c0eac7"}, +] + +[[package]] +name = "urllib3" +version = "2.2.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, + {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.8" +content-hash = "73b587bcfa37ed87d1848212bfa57b895291d2981d310579c578ccea158cf2a1" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..caa6475 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "solapi" +version = "5.0.0" +description = "SOLAPI SDK for Python" +authors = [ + { name = "SOLAPI Team", email = "contact@solapi.com" } +] +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "requests (>=2.32.3,<3.0.0)" +] + +[tool.poetry] +packages = [{ include = "solapi", from = "src" }] + +[tool.poetry.group.dev.dependencies] +ruff = "^0.9.10" + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/src/examples/modules/README.md b/src/examples/modules/README.md deleted file mode 100644 index 6be2e4f..0000000 --- a/src/examples/modules/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# SOLAPI Python 기본 예제 - -src/lib/config.ini 파일을 생성하신 다음, 아래와 같이 설정 후 examples 아래 예제 코드를 실행해 보세요. - -``` -[AUTH] -# 계정의 API Key와 API Secret을 입력해주세요 -api_key = [API KEY] -api_secret = [API SECRET] - -[SERVER] -domain = api.solapi.com -protocol = https -prefix = -``` - -### 문자 발송 예제 - -``` -sms/send_sms.py SMS 발송 예제(해외 발송 포함) -sms/send_lms.py LMS 발송 예제 -sms/send_mms.py MMS 발송 예제 -sms/allow_duplicates.py 수신번호 중복 허용 예제 -rcs/send_rcs_sms.py RCS SMS 발송 예제(버튼 포함) -rcs/send_rcs_lms.py RCS LMS 발송 에제(버튼 포함) -``` - -### 카카오톡(알림톡, 친구톡) 발송 예제 - -``` -kakaotalk/send_alimtalk.py 알림톡 발송 예제 -kakaotalk/send_chingutalk.py 친구톡 발송 예제 -``` - -### 네이버톡톡 발송 예제 - -``` -send_naver.py 네이버톡톡 예제 -``` - -### 메시지 조회 - -``` -message_list.py 메시지 목록 조회 -``` - -### 기타 - -``` -senderid 발신번호 등록 및 인증 예제 -group 그룹발송 및 예약발송 예제 -storage MMS, 친구톡, RCS 파일(이미지) 관리 -``` diff --git a/src/examples/modules/group/README.md b/src/examples/modules/group/README.md deleted file mode 100644 index 349d5e9..0000000 --- a/src/examples/modules/group/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# 그룹 추가 및 그룹 내 대량 발송 예제 - -create_group.py를 제외한 나머지 예제들은 모두 groupId가 있는 것을 전제로 제작 되었습니다. -그룹 발송 시작 전 반드시 groupId를 체크 부탁드리며, groupId가 없는 경우 create_group.py 파일을 참고해주세요. - -### 발송 관련 예제 - -``` -add_group_message.py -> 그룹에 메시지 추가 예제, 즉시 발송 안됨 -add_group_message_with_image -> 그룹에 사진문자 추가 예제, 즉시 발송 안됨 -send_group_message.py -> 대기 상태인 그룹 건 즉시 발송 예제 -``` - -### 예약발송 관련 예제 - -``` -set_reservation_group.py -> 예약발송 설정 예제 -cancel_reservation_group -> 예약발송 취소 예제 -``` - -### 기타 - -``` -create_group.py -> 그룹 생성 예제 -delete_group.py -> 그룹 비활성화 예제 -get_group_info.py -> 단 건 그룹 조회 예제 -get_group_list.py -> 여러 그룹 건 조회 예제 -``` \ No newline at end of file diff --git a/src/examples/modules/group/add_group_message.py b/src/examples/modules/group/add_group_message.py deleted file mode 100644 index 28a7181..0000000 --- a/src/examples/modules/group/add_group_message.py +++ /dev/null @@ -1,29 +0,0 @@ -import json -from src.lib import message - -if __name__ == '__main__': - # [INPUT_GROUP_ID] 에 그룹 아이디를 넣어주세요 - # ex) G4V20181005122748TESTTESTTESTTES - data = { - 'messages': [ - { - 'to': '수신번호 입력', - 'from': '발신번호 입력', - 'text': '예제 메시지' - }, - { - 'to': '수신번호2 입력', - 'from': '발신번호2 입력', - 'text': '예제 메시지2' - }, - { - # 해외 문자 - 'country': '국가번호 입력', - 'to': '현지 수신번호', - 'from': '발신번호 입력', - 'text': 'Hello There' - } - ] - } - res = message.put('/messages/v4/groups/[INPUT_GROUP_ID]/messages', data=data) - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/group/add_group_message_with_image.py b/src/examples/modules/group/add_group_message_with_image.py deleted file mode 100644 index 6e94c30..0000000 --- a/src/examples/modules/group/add_group_message_with_image.py +++ /dev/null @@ -1,28 +0,0 @@ -import json -from src.lib import message - -''' -사진 문자 그룹 추가 예제 -''' -if __name__ == '__main__': - # [GROUP_ID] 에 그룹 아이디를 넣어주세요 - # ex) G4V20181005122748TESTTESTTESTTES - # [IMAGE_ID] 에 이미지 아이디를 넣어주세요 - data = { - 'messages': json.dumps([ - { - 'to': '수신번호 입력', - 'from': '발신번호 입력', - 'text': '발송 예제 #1', - 'imageId': '[IMAGE_ID]' - }, - { - 'to': '수신번호2 입력', - 'from': '발신번호2 입력', - 'text': '발송 예제 #2', - 'imageId': '[IMAGE_ID]' - } - ]) - } - res = message.put('/messages/v4/groups/[GROUP_ID]/messages', data=data) - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/group/cancel_reservation_group.py b/src/examples/modules/group/cancel_reservation_group.py deleted file mode 100644 index f1c1888..0000000 --- a/src/examples/modules/group/cancel_reservation_group.py +++ /dev/null @@ -1,11 +0,0 @@ -import json -from src.lib import message - -''' -예약 발송 취소 예제, 이미 예약 발송이 설정되어 있어야 합니다. -''' -if __name__ == '__main__': - # [INPUT_GROUP_ID] 에 그룹 아이디를 넣어주세요 - # ex) G4V20181005122748TESTTESTTESTTES - res = message.delete('/messages/v4/groups/[INPUT_GROUP_ID]/schedule', data=None) - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/group/create_group.py b/src/examples/modules/group/create_group.py deleted file mode 100644 index 87a614b..0000000 --- a/src/examples/modules/group/create_group.py +++ /dev/null @@ -1,6 +0,0 @@ -import json -from src.lib import message - -if __name__ == '__main__': - res = message.post('/messages/v4/groups', data=message.default_agent) - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/group/delete_group_info.py b/src/examples/modules/group/delete_group_info.py deleted file mode 100644 index 99a1d52..0000000 --- a/src/examples/modules/group/delete_group_info.py +++ /dev/null @@ -1,8 +0,0 @@ -import json -from src.lib import message - -if __name__ == '__main__': - # [INPUT_GROUP_ID] 에 그룹 아이디를 넣어주세요 - # ex) G4V20181005122748TESTTESTTESTTES - res = message.delete('/messages/v4/groups/[INPUT_GROUP_ID]') - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/group/delete_group_message.py b/src/examples/modules/group/delete_group_message.py deleted file mode 100644 index 4c0a9e4..0000000 --- a/src/examples/modules/group/delete_group_message.py +++ /dev/null @@ -1,17 +0,0 @@ -import json -from src.lib import message - -if __name__ == '__main__': - # [INPUT_GROUP_ID] 에 그룹 아이디를 넣어주세요 - # [INPUT_MESSAGE_ID] 에 메세지 아이디를 넣어주세요 - # ex) G4V20181005122748TESTTESTTESTTES - data = {'messageIds': - [ - # 배열로 id를 입력하여 한 요청에 여러개의 메세지를 제거할 수 있습니다. - '[INPUT_MESSAGE_ID]', - '[INPUT_MESSAGE_ID]' - ] - } - res = message.delete('/messages/v4/groups/[INPUT_GROUP_ID]/messages', - data=data) - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/group/get_group_info.py b/src/examples/modules/group/get_group_info.py deleted file mode 100644 index 955067e..0000000 --- a/src/examples/modules/group/get_group_info.py +++ /dev/null @@ -1,8 +0,0 @@ -import json -from src.lib import message - -if __name__ == '__main__': - # [INPUT_GROUP_ID] 에 그룹 아이디를 넣어주세요 - # ex) G4V20181005122748TESTTESTTESTTES - res = message.get('/messages/v4/groups/[INPUT_GROUP_ID]') - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/group/get_group_list.py b/src/examples/modules/group/get_group_list.py deleted file mode 100644 index 9c57557..0000000 --- a/src/examples/modules/group/get_group_list.py +++ /dev/null @@ -1,6 +0,0 @@ -import json -from src.lib import message - -if __name__ == '__main__': - res = message.get('/messages/v4/groups') - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/group/get_group_message.py b/src/examples/modules/group/get_group_message.py deleted file mode 100644 index 631ee69..0000000 --- a/src/examples/modules/group/get_group_message.py +++ /dev/null @@ -1,8 +0,0 @@ -import json -from src.lib import message - -if __name__ == '__main__': - # [INPUT_GROUP_ID] 에 그룹 아이디를 넣어주세요 - # ex) G4V20181005122748TESTTESTTESTTES - res = message.get('/messages/v4/groups/[INPUT_GROUP_ID]/messages') - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/group/send_group_message.py b/src/examples/modules/group/send_group_message.py deleted file mode 100644 index c6cc443..0000000 --- a/src/examples/modules/group/send_group_message.py +++ /dev/null @@ -1,8 +0,0 @@ -import json -from src.lib import message - -if __name__ == '__main__': - # [INPUT_GROUP_ID] 에 그룹 아이디를 넣어주세요 - # ex) G4V20181005122748TESTTESTTESTTES - res = message.post('/messages/v4/groups/[INPUT_GROUP_ID]/send', data={}) - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/group/set_reservation_group.py b/src/examples/modules/group/set_reservation_group.py deleted file mode 100644 index fb52e8f..0000000 --- a/src/examples/modules/group/set_reservation_group.py +++ /dev/null @@ -1,24 +0,0 @@ -import json -import time -import datetime -from src.lib import message - -''' -예약 발송 예제, 미리 그룹이 추가되어 있어야 합니다. -''' -if __name__ == '__main__': - utc_offset_sec = time.altzone if time.localtime().tm_isdst else time.timezone - utc_offset = datetime.timedelta(seconds=-utc_offset_sec) - - # 희망하시는 예약 날짜를 넣어주세요 - # datetime(년, 월, 일, 시, 분, 초) - scheduledDate = datetime.datetime(2022, 2, 8, 0, 0, 0).replace( - tzinfo=datetime.timezone(offset=utc_offset)).isoformat() - data = { - 'scheduledDate': scheduledDate - } - - # [INPUT_GROUP_ID] 에 그룹 아이디를 넣어주세요 - # ex) G4V20181005122748TESTTESTTESTTES - res = message.post('/messages/v4/groups/[INPUT_GROUP_ID]/schedule', data=data) - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/kakaotalk/send_alimtalk.py b/src/examples/modules/kakaotalk/send_alimtalk.py deleted file mode 100644 index a2bb0a7..0000000 --- a/src/examples/modules/kakaotalk/send_alimtalk.py +++ /dev/null @@ -1,73 +0,0 @@ -import json -from src.lib import message - -''' -알림톡 발송 예제 -한번 요청으로 10,000건의 알림톡 발송이 가능합니다. -''' -if __name__ == '__main__': - data = { - 'messages': [ - # 변수가 있는 경우 - { - 'to': '01000000001', - 'from': '029302266', - 'kakaoOptions': { - 'pfId': 'KA01PF200323182344986oTFz9CIabcx', - 'templateId': 'KA01TP200323182345741y9yF20aabcx', - # 변수: 값 형식으로 모든 변수에 대한 변수값 입력 - 'variables': { - '#{변수1}': '변수1의 값', - '#{변수2}': '변수2의 값', - '#{버튼링크1}': '버튼링크1의 값', - '#{버튼링크2}': '버튼링크2의 값', - '#{강조문구}': '강조문구의 값' - } - } - }, - # 변수가 없는 경우 - { - 'to': '01000000001', - 'from': '029302266', - 'kakaoOptions': { - 'pfId': 'KA01PF200323182344986oTFz9CIabcx', - 'templateId': 'KA01TP200323182345741y9yF20aabcx', - 'variables': {} # 변수가 없는 경우에도 입력 - } - }, - { - 'to': ['01000000002', '01000000003'], # array 사용으로 동일한 내용을 여러 수신번호에 전송 가능 - 'from': '029302266', - 'kakaoOptions': { - 'pfId': 'KA01PF200323182344986oTFz9CIabcx', - 'templateId': 'KA01TP200323182345741y9yF20aabcx', - 'variables': { - '#{변수1}': '변수1의 값', - '#{변수2}': '변수2의 값', - '#{버튼링크1}': '버튼링크1의 값', - '#{버튼링크2}': '버튼링크2의 값', - '#{강조문구}': '강조문구의 값' - } - } - }, - { - 'to': ['01000000002', '01000000003'], # array 사용으로 동일한 내용을 여러 수신번호에 전송 가능 - 'from': '029302266', - 'kakaoOptions': { - 'pfId': 'KA01PF200323182344986oTFz9CIabcx', - 'templateId': 'KA01TP200323182345741y9yF20aabcx', - 'disableSms': True, # 해당 값을 True로 표시할 경우 문자로의 대체 발송이 진행되지 않습니다. - 'variables': { - '#{변수1}': '변수1의 값', - '#{변수2}': '변수2의 값', - '#{버튼링크1}': '버튼링크1의 값', - '#{버튼링크2}': '버튼링크2의 값', - '#{강조문구}': '강조문구의 값' - } - } - } - # 한 번의 시도로 최대 10,000건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/kakaotalk/send_chingutalk.py b/src/examples/modules/kakaotalk/send_chingutalk.py deleted file mode 100644 index 4d2a3ee..0000000 --- a/src/examples/modules/kakaotalk/send_chingutalk.py +++ /dev/null @@ -1,72 +0,0 @@ -import json -from src.lib import message - -''' -한번 요청으로 1만건의 친구톡 발송이 가능합니다. -카카오톡채널 친구로 추가되어 있어야 친구톡 발송이 가능합니다. -템플릿 등록없이 버튼을 포함하여 자유롭게 메시지 전송이 가능합니다. -''' -if __name__ == '__main__': - data = { - 'messages': [ - { - 'to': '01000000001', - 'from': '029302266', - 'text': '카카오톡채널 친구로 추가되어 있어야 친구톡 발송이 가능합니다.', - 'kakaoOptions': { - 'pfId': 'KA01PF200323182344986oTFz9CIabcx', - 'adFlag': True # 광고 표기 여부(기본값 False) - } - }, - { - 'to': ['01000000002', '01000000003'], # array 사용으로 동일한 내용을 여러 수신번호에 전송 가능 - 'from': '029302266', - 'text': '카카오톡채널 친구로 추가되어 있어야 친구톡 발송이 가능합니다.', - 'kakaoOptions': { - 'pfId': 'KA01PF200323182344986oTFz9CIabcx' - } - }, - { - 'to': '01000000004', - 'from': '029302266', - 'text': '버튼은 최대 5개까지 추가 가능하며 내용과 마찬가지로 자유롭게 입력이 가능합니다.', - 'kakaoOptions': { - 'pfId': 'KA01PF200323182344986oTFz9CIabcx', - 'buttons': [ - { - 'buttonType': 'WL', # 웹링크 - 'buttonName': '버튼 이름', - 'linkMo': 'https://m.example.com', - 'linkPc': 'https://example.com' # 템플릿 등록 시 모바일링크만 입력하였다면 linkPc 값은 입력하시면 안됩니다. - }, - { - 'buttonType': 'AL', # 앱링크 - 'buttonName': '실행 버튼', - 'linkAnd': 'examplescheme://', - 'linkIos': 'examplescheme://' - }, - { - 'buttonType': 'BK', # 봇키워드(챗봇에게 키워드를 전달합니다. 버튼이름의 키워드가 그대로 전달됩니다.) - 'buttonName': '봇키워드' - }, - { - 'buttonType': 'MD', # 상담요청하기 (상담요청하기 버튼을 누르면 메시지 내용이 상담원에게 그대로 전달됩니다.) - 'buttonName': '상담요청하기' - }, - { - 'buttonType': 'BC', # 상담톡 서비스 사용 시에만 가능합니다. - 'buttonName': '상담요청하기' - }, - { - 'buttonType': 'BT', # 챗봇 사용 시 가능 - 'buttonName': '챗본 문의' - } - ] - } - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/message_list.py b/src/examples/modules/message_list.py deleted file mode 100644 index 1430fce..0000000 --- a/src/examples/modules/message_list.py +++ /dev/null @@ -1,57 +0,0 @@ -import requests -import json -from src.lib import config, auth - -''' -모든 메시지를 조회하는 예제 -''' -if __name__ == '__main__': - res = requests.get(config.get_url('/messages/v4/list'), - headers=auth.get_headers(config.api_key, config.api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # limit - res = requests.get(config.get_url('/messages/v4/list?limit=10'), - headers=auth.get_headers(config.api_key, config.api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # messageId - res = requests.get(config.get_url('/messages/v4/list?messageId=XXXXXXX'), - headers=auth.get_headers(config.api_key, config.api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # groupId - res = requests.get(config.get_url('/messages/v4/list?groupId=XXXXXXX'), - headers=auth.get_headers(config.api_key, config.api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # 수신번호로 조회 - res = requests.get(config.get_url('/messages/v4/list?to=01000000001'), - headers=auth.get_headers(config.api_key, config.api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # 발신번호로 조회 - res = requests.get(config.get_url('/messages/v4/list?from=029302266'), - headers=auth.get_headers(config.api_key, config.api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # 메시지타입으로 조회 - res = requests.get(config.get_url('/messages/v4/list?type=SMS'), - headers=auth.get_headers(config.api_key, config.api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # 메시지 상태 코드로 조회 - res = requests.get(config.get_url('/messages/v4/list?statusCode=4000'), - headers=auth.get_headers(config.api_key, config.api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # 페이지 처리 - page1 = requests.get(config.get_url('/messages/v4/list?limit=5'), - headers=auth.get_headers(config.api_key, config.api_secret)).json() - print(json.dumps(page1, indent=2, ensure_ascii=False)) - if 'nextKey' in res: - # 읽어올 데이터가 더 있음 - startKey = res['nextKey'] - page2 = requests.get(config.get_url('/messages/v4/list?limit=5&startKey=%s' % startKey), - headers=auth.get_headers(config.api_key, config.api_secret)).json() - print(json.dumps(page2, indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/naver/__init__.py b/src/examples/modules/naver/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/examples/modules/naver/send_naver.py b/src/examples/modules/naver/send_naver.py deleted file mode 100644 index d569e9d..0000000 --- a/src/examples/modules/naver/send_naver.py +++ /dev/null @@ -1,58 +0,0 @@ -import json -from src import lib as message - -# 한 번 요청으로 10,000건의 네이버 톡톡(스마트알림) 발송이 가능합니다. -# 등록되어 있는 템플릿의 변수 부분을 제외한 나머지 부분(상수)은 100% 일치해야 합니다. -# 템플릿 내용이 "#{이름}님 가입을 환영합니다."으로 등록되어 있는 경우 변수 #{이름}을 홍길동으로 치환하여 "홍길동님 가입을 환영합니다."로 입력해 주세요. -if __name__ == '__main__': - data = { - 'messages': [ - { - 'type': 'NSA', - 'to': '01000000001', - 'from': '029302266', - 'text': '홍길동님 가입을 환영합니다.', - 'naverOptions': { - 'talkId': 'KA01PF200323182344986oTFz9CIabcx', - 'templateId': 'KA01TP200323182345741y9yF20aabcx' - } - }, - { - 'type': 'NSA', - 'to': ['01000000002', '01000000003'], # array 사용으로 동일한 내용을 여러 수신번호에 전송 가능 - 'from': '029302266', - 'text': '모두님 가입을 환영합니다.', - 'naverOptions': { - 'talkId': 'KA01PF200323182344986oTFz9CIabcx', - 'templateId': 'KA01TP200323182345741y9yF20aabcx' - } - }, - { - 'type': 'NSA', - 'to': '01000000004', - 'from': '029302266', - 'text': '버튼은 최대 5개까지 추가 가능하며 템플릿 내용은 검수 받은 내용 그대로 입력되어야 하며, 버튼 URL은 자유롭게 입력 가능합니다.', - 'naverOptions': { - 'talkId': 'KA01PF200323182344986oTFz9CIabcx', - 'templateId': 'KA01TP200323182345741y9yF20aabcx', - 'buttons': [ - { - 'buttonType': 'WL', # 웹링크 - 'buttonCode': 'btn1', # 버튼 코드를 입력하세요. (템플릿 상세보기에서 확인 가능) - 'linkMo': 'https://m.example.com', # URL은 자유롭게 입력 가능 - 'linkPc': 'https://example.com' # URL은 자유롭게 입력 가능 - }, - { - 'buttonType': 'AL', # 앱링크 - 'buttonCode': 'btn2', # 버튼 코드를 입력하세요. (템플릿 상세보기에서 확인 가능) - 'linkAnd': 'examplescheme://', # 안드로이드 - 'linkIos': 'examplescheme://' # iOS - } - ] - } - } - # 최대 10,000건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/rcs/__init__.py b/src/examples/modules/rcs/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/examples/modules/rcs/images/sample1.png b/src/examples/modules/rcs/images/sample1.png deleted file mode 100644 index 50c71287c2247a6d9b03c601d69dbc82eaca1c7a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4836 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 Gum1vgD@$Sk diff --git a/src/examples/modules/rcs/images/sample3.png b/src/examples/modules/rcs/images/sample3.png deleted file mode 100644 index 7dca86aa648d06b464dd1ab43d6826222cbdb6aa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4817 zcmeI0`#;l*AIF8_h-y6KNUZEg33J-f!Wc!XL#@v{O z(lR=E!uzb2)+YP^kN%bo1)r9CT}#XO@kwv5^SH1D{uNs2h`-*KU%GBgewQ2Bl4D}B z{XoSDzoX=n5taI0{@&&uPl;MR`zsr>D;!Q%4IC}scIN)=hd<`+i1^Jj{i(k)qpZU3 ztk*5;r?JMiuje@CF}4)T@`7ZjOupv9R$oHKLzOAde|#xqrPL`ylv{(CXFz#NF3gWQNCH)*^%WkZ1-dQyFpQx21zE6coat*Q|b@=eUGesfI?h% zjo>oLW^o93?d4UruqiNc?h*Lv^I?+;^bg_-jWmag9fzy)#vmS=w|^#2SB$Qo18J^(H!0a>#IB!$@Mx^-+%Hhgx*tcmtyH;W z_yjW!+e!^gySciQ7%npZF|%uV-Vypeh9HiakF2s@OuM|QVbo?dVMh`I3ccV0xU+j_ z&ma}C6QHQY!^^KMLC~g)a)x0$5^+Q&d)CgJWs(UgMuq8$QAvqg33sIJCW;f9FH^SX zgGgxwlE9Gh4>-IqG(0p+;(IuRa-bEL%$a#N`oCxJex(<_ZifA!kGNI=N2QKdk_{gU zKeBOH6;xoJ*ma3I!W&1njKH?h44H;hGVhUmXfifGc2|~m^zLpj$#qiwfC9}gTOA(nWFr$@Cr=Te!6B`Kc3U^l zZ3$ao@GNF9udg(H$L)JQ#I2oeHP@DmrmPz+Cx+su5WFKdLcw`L?>M1T3wxzl&pH>q zn3tn-PzSqi!4MJGXU0J#brx|0n&n&Vt~k3%hjRLx#GLpslfts`@J`QBfmkIWEhV zzLD63ShJGOadMI$toD)~`(zaCmH#tXF}ds<|IxX$*bbh#FPMy9Elh_R0;5sx9@O+8adTLQwE>oa_Hb-|VZV&m+omDEdB zqHg6C#i(T3Y(m_137_o2_Tsjk z9Fh^1YDe2%1CEMW8{yR_7exVg`O^B&kHd_ka@bDz0sNr(uzaurgPA$obY`x=6|~V} zkB$oV_glQhPs2kLMk9A}m5ap-(Z!Slr{Yt^bAq)qoQ!i~cBgzsF$dngzIM#2+A_#@ z9Nk0jFchjX6L(+D(w=LfngPxX!xiFm0tbv{{j{6Z!~c}m@d!&%D9myW{9#}F^6ch- zvKyqXMsgi>9@_H;sd`_Jxw77knU{dwi(!J}P9DC5@mp9!c6115BzFckCAcV7iu7rv z5G0yd3*@yZlM6{!V0T2!qrS+W!bPih`CIZ(f`ctsi9+a_8{aD~M=JWBmuIH8zZ$F( zzv?X!;UB?Rn%`w~DGfq(=!IpvD^3aDPFOU|=3#f>8}quB|FYA#ZE!<{S-CsMM9M2e zAW37La-0}K?|V_5mRYbgBbvEOxi25c?}Sr?ODZz-@ooROAzbHW$;ep?RF|?*3Y73) zYXEF!*X=`Jmhzs=0=9aPbz;Q1%Ljg@R~XPDYIp-ZdQYob*wFLDbt!g_dg(Jz9RG1Q z@FrHwvg^vBlFgCUFhLOBMLYT>Z;4;-vK@u+VBQYhXipbTOUgFhiLlRd&LCO1=J$GY zLQdhqjkBCC;qL@&;5;j1D^j*f)Nho>R$lh@m^nmB9UGpo=$h5(7@~@e7Wh6C(wlwk zST2OelA5y+m18?+7&d+wq&hRMxkTz4ySvY+hs#rygl#mLwU87pj|flo(>cg{mQ>v- z?^d1{ri|6wMy~@L)#{h{Z4RtbN9OL@qBom|{RjS0Z8d3B)LIj5_Lbx@i_?IQZxUk+li%6I*?1j|jZ7Q!;A`D-A4_Bp`PK_Cq2FWYUQDaS{YxZuOz;D j;%gNC%can+*>w1w*yLVpd-%qyO6#Q0Y46&j;kW(;U^}jA diff --git a/src/examples/modules/rcs/images/sample4.png b/src/examples/modules/rcs/images/sample4.png deleted file mode 100644 index 8eb7217b66915eb28db01da73b9ec0ac8212ea2f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4740 zcmeHL>08rR7RC@Z4PX?BwE~vtL#qXfj3NyJl_jkUK~X^=0R&1l0tG|}WD!w}t&qY1 zu~HH{+9H*b2xZFxR6&p}GHAk<1OovfkdR*>F=U&}zcIsy`{lg%JXp=C!`} z0Vbg)l!;vF-CPfE^JiD8MVjzmBu>y?H#g<$JK=bP%!a~xzQdhnTpg;rMDsEzDKD=7 z71_|We~(K_2$=t{cy44go7Uv?3-X*}!I-EgNS+Ce?pz1fvGE@(=ltCAkvyWBOfnph z_;vc@f8vy0f1;^bs~?bk)cAIJ)`N0n<|8=KAmfTNj}%rOGQM+!;!1dj%`w<73U@Ge zDl2L~w=x+u(AM@LQP2G4nSaupl+?~p{8AE>k=HZ)4A$1QEpHwXABus+dQYW3(MwSwZw=I0)wx(U12w{ihX6sg<; z4GT5&>csp%qQW>j;Xv8+pBXNhzx#R~N;w*&PYmr)d@LT*b{Igi2|;Pwq_9dB=Ybdp zM5}$+0u)*+dfy%oB_k}Gs0ed6}@i?bEJK2+Dz;@T6xVi!$ zTam-q=OjpDboKM{U;4Uxk+j@%S#GX|_hgRsch&Gc%(>OYTgU0z!J1Ao4qV>ZO*dTa zMh3A=-U-EnY*DjaHILvWDRPU8paK^f0$8u$Sn&^euZtWU>eoEP#_QcrG0nTtLW3MiZR-Y(yPgxhbEqC= zaX|-)(g=f}1i`U=k63uj@|<71>+P5zMI^HBMb%56(+yp_#^yYN{3XYuC%7vChjc~_ zJW&5m>Llxuh2xSdO;U9o%Ck0nGPZaaA$n#UH$M12xaZP7s@`~2n>z689tTJ zG*fzA-24xDL)a>K9fSpWnG;K40^<1fGu?2v_NL$G1N|gts27P<$ot;-cYuj)bsnSX zj~WcH8YGaWEpuImq&i>~D5u5+&e&(f7@LO754Z37wa7|8_Be2Y)Y7!fwZt8Le;M`T z2mQQXQUh*?j71-Cx7^G4YFVAEKM}tx^Vcn&iuPBh4h!=6QP1@_YqYgUtv0KwbP8zd z{LoXw=v37VoIXzz3Ezj?*qpaMSv*|`TWNhEw+SM*@d#>%`sHBbhhA#b3(HnWb1OGZ zyTBv4W>VP@G`Kfm9E8mqMHNhtWpSzg``9bD{c6#&^c33w3DhP&SS(sr(MkT2z90hLVh`gZT*N!S5Y7Gc96S27YE66X$flmMm*=O*DMyg=*S*<@Pw;Bz*Y$ zV(FLB#X0G0m&Bv*?4Z%yMPMD)!PF(WX)n?i=K_t(8Bjm&!b;fpkVSdhXHG+yknl#u(j+GgwhJkznhU?PPl~mdiW8Yvy&!%gm_a$ zIW(JO+nz?hDV?#@#_{tL2|8ckyBW3^J$J;gK#ew13oC0ID7Zm+!8!YSPDBglERV5t zw&mIoq18nT0?GOmBc|v!+LzLiVCz>ziZ5s36jb~A+jN}JgJGIIRD<6}v~T1bq36B^ z#viY#_))F9n+z3tA~I6P?GQuH7fTI_&Vms0-PZdkR;4YIP`)~v>S|mGp>KA)!aZX$ z3(nf}HYf5I*uCthQ^J1sEnH|b;qhOX55sh6T~c5LZn}k_)VH*vjq4%lI;(#Z846g; zq@cD1+h6;}7q5DggLrd&OFe`WkTU40nd$Kk%rgNarI3~YV9+2=8+@3Od9rdp)4CZ! zDF^zl2u16BAPws(Jw7Klkz%s%N*Mpb2JeXCU<65Dc*R6 zThuB~4HVK*+W8i7lD+mrNT79DEM|(wF18WnOm%!gX9$hzBImz-Fn@II`pW{igmeAv zYCNewzAa#}k1;Y!FMEX`P}>KydHA!*v>8@)lq zG0>5h13O|uV8JvNdS+L(w_S!J+?ZurN*xg70a!1abZvcLM)z1ri@MKlj{1d*0k^5dk$VK&?H|hx1pstiC^VNnu{;%ixP6!TJcrw{qXfi~7i-eSQ#U9$KlEF!ewSi;T!0E@yC Ae*gdg diff --git a/src/examples/modules/rcs/images/sample5.png b/src/examples/modules/rcs/images/sample5.png deleted file mode 100644 index 7b653bdd95a6aad6dbdaf08308d3eea765d99d56..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4804 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 ac3U$Q7k~Bny4CNL&cXdh{j2@Zm;VoeGb!l+ diff --git a/src/examples/modules/rcs/images/sample6.png b/src/examples/modules/rcs/images/sample6.png deleted file mode 100644 index 3ddfc6bc1d13a7e4b1d37799f7dd9986b7b55b91..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4733 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 diff --git a/src/examples/modules/rcs/send_rcs_lms.py b/src/examples/modules/rcs/send_rcs_lms.py deleted file mode 100644 index bb2ce04..0000000 --- a/src/examples/modules/rcs/send_rcs_lms.py +++ /dev/null @@ -1,43 +0,0 @@ -import json -from src.lib import message - -# 한번 요청으로 1만건의 메시지 발송이 가능합니다. -if __name__ == '__main__': - data = { - 'messages': [ - { - 'to': '01000000001', - 'from': '029302266', # 반드시 RCSBizCenter에 등록된 발신번호 입력 - 'subject': 'LMS 제목', - 'text': 'RCS LMS를 발송합니다.', - 'rcsOptions': { - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - } - }, - # 버튼이 포함된 RCS LMS를 발송합니다. 버튼은 최대 3개까지 추가 가능합니다. - { - 'to': ['01000000002', '01000000003'], # 신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'subject': 'LMS 제목', - 'text': '버튼이 포함된 RCS LMS를 발송합니다. 버튼은 최대 3개까지 추가 가능합니다.', - 'rcsOptions': { - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - 'buttons': [ - {'buttonType': 'WL', 'buttonName': '홈페이지 바로가기', 'link': 'https://nurigo.net'} - , {'buttonType': 'ML', 'buttonName': '지도 위치 표시', 'latitude': '37.280342669603684', - 'longitude': '127.11824209721874', 'label': '누리고', 'link': 'https://nurigo.net'} - , {'buttonType': 'MQ', 'buttonName': '지도 검색', 'link': 'https://nurigo.net', 'query': '(주)누리고'} - # , { 'buttonType': 'MR', 'buttonName': '나의 현재 위치' } - # , { 'buttonType': 'CA', 'buttonName': '캘린더 일정 생성', 'title': '제목', 'startTime': '2021-06-19T00:00:00.000Z', 'endTime': '2021-06-19T09:00:00.000Z', 'text': '메모' } - # , { 'buttonType': 'CL', 'buttonName': '텍스트 복사', 'text': '복사할 텍스트 내용' } - # , { 'buttonType': 'DL', 'buttonName': '전화 걸기', 'phone': '01012345678' } - # , { 'buttonType': 'MS', 'buttonName': '메시지 보내기', 'phone': '01012345678', 'text': '보낼 메시지 내용' } - ] - } - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/rcs/send_rcs_mms.py b/src/examples/modules/rcs/send_rcs_mms.py deleted file mode 100644 index cb1fbad..0000000 --- a/src/examples/modules/rcs/send_rcs_mms.py +++ /dev/null @@ -1,48 +0,0 @@ -import json -from src.lib import message, storage - -# 한번 요청으로 1만건의 메시지 발송이 가능합니다. -if __name__ == '__main__': - res = storage.upload_rcs_image('../testImage.jpg').json() - fileId = res['fileId'] - - data = { - 'messages': [ - { - 'to': '01000000001', - 'from': '029302266', # 반드시 RCSBizCenter에 등록된 발신번호 입력 - 'subject': 'MMS 제목', - 'text': 'RCS MMS를 발송합니다.', - 'imageId': fileId, - 'rcsOptions': { - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - } - }, - # 버튼이 포함된 RCS MMS를 발송합니다. 버튼은 최대 2개까지 추가 가능합니다. - { - 'to': ['01000000002', '01000000003'], # 신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'subject': 'MMS 제목', - 'text': '버튼이 포함된 RCS MMS를 발송합니다. 버튼은 최대 2개까지 추가 가능합니다.', - 'imageId': fileId, - 'rcsOptions': { - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - 'buttons': [ - {'buttonType': 'WL', 'buttonName': '홈페이지 바로가기', 'link': 'https://nurigo.net'} - , {'buttonType': 'ML', 'buttonName': '지도 위치 표시', 'latitude': '37.280342669603684', - 'longitude': '127.11824209721874', 'label': '누리고', 'link': 'https://nurigo.net'} - # , { 'buttonType': 'MQ', 'buttonName': '지도 검색', 'link': 'https://nurigo.net', 'query': '(주)누리고' } - # , { 'buttonType': 'MR', 'buttonName': '나의 현재 위치' } - # , { 'buttonType': 'CA', 'buttonName': '캘린더 일정 생성', 'title': '제목', 'startTime': '2021-06-19T00:00:00.000Z', 'endTime': '2021-06-19T09:00:00.000Z', 'text': '메모' } - # , { 'buttonType': 'CL', 'buttonName': '텍스트 복사', 'text': '복사할 텍스트 내용' } - # , { 'buttonType': 'DL', 'buttonName': '전화 걸기', 'phone': '01012345678' } - # , { 'buttonType': 'MS', 'buttonName': '메시지 보내기', 'phone': '01012345678', 'text': '보낼 메시지 내용' } - ] - } - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/rcs/send_rcs_mms_m3.py b/src/examples/modules/rcs/send_rcs_mms_m3.py deleted file mode 100644 index 550a714..0000000 --- a/src/examples/modules/rcs/send_rcs_mms_m3.py +++ /dev/null @@ -1,59 +0,0 @@ -import json -from src.lib import message, storage - -# 한번 요청으로 1만건의 메시지 발송이 가능합니다. -if __name__ == '__main__': - res = storage.upload_rcs_image('images/sample1.png').json() - sample1 = res['fileId'] - res = storage.upload_rcs_image('images/sample2.png').json() - sample2 = res['fileId'] - res = storage.upload_rcs_image('images/sample3.png').json() - sample3 = res['fileId'] - - # 카드 3개 발송 예제(각 카드별 버튼 2개까지 가능) - data = { - 'messages': [ - { - 'to': ['01000000001', '01000000002'], # 신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'subject': 'Sample 1', - 'text': 'MMS M3 타입 발송 예제입니다.', - 'imageId': sample1, - 'rcsOptions': { - 'mmsType': 'M3', # M3 ~ M6 (총 이미지 1M를 넘을 수 없음) - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - 'buttons': [ - {'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'} - # , { 'buttonType': 'ML', 'buttonName': '지도 위치 표시', 'latitude': '37.280342669603684', 'longitude': '127.11824209721874', 'label': '누리고', 'link': 'https://nurigo.net' } - # , { 'buttonType': 'MQ', 'buttonName': '지도 검색', 'link': 'https://nurigo.net', 'query': '(주)누리고' } - # , { 'buttonType': 'MR', 'buttonName': '나의 현재 위치' } - # , { 'buttonType': 'CA', 'buttonName': '캘린더 일정 생성', 'title': '제목', 'startTime': '2021-06-19T00:00:00.000Z', 'endTime': '2021-06-19T09:00:00.000Z', 'text': '메모' } - # , { 'buttonType': 'CL', 'buttonName': '텍스트 복사', 'text': '복사할 텍스트 내용' } - # , { 'buttonType': 'DL', 'buttonName': '전화 걸기', 'phone': '01012345678' } - # , { 'buttonType': 'MS', 'buttonName': '메시지 보내기', 'phone': '01012345678', 'text': '보낼 메시지 내용' } - ], - 'additionalBody': [ - { - 'imageId': sample2, - 'title': 'Sample 2', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - }, - { - 'imageId': sample3, - 'title': 'Sample 3', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - } - ] - } - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/rcs/send_rcs_mms_m6.py b/src/examples/modules/rcs/send_rcs_mms_m6.py deleted file mode 100644 index e0b6826..0000000 --- a/src/examples/modules/rcs/send_rcs_mms_m6.py +++ /dev/null @@ -1,86 +0,0 @@ -import json -from src.lib import message, storage - -# 한번 요청으로 1만건의 메시지 발송이 가능합니다. -if __name__ == '__main__': - res = storage.upload_rcs_image('images/sample1.png').json() - sample1 = res['fileId'] - res = storage.upload_rcs_image('images/sample2.png').json() - sample2 = res['fileId'] - res = storage.upload_rcs_image('images/sample3.png').json() - sample3 = res['fileId'] - res = storage.upload_rcs_image('images/sample4.png').json() - sample4 = res['fileId'] - res = storage.upload_rcs_image('images/sample5.png').json() - sample5 = res['fileId'] - res = storage.upload_rcs_image('images/sample6.png').json() - sample6 = res['fileId'] - - # 카드 6개 발송 예제(각 카드별 버튼 2개까지 가능) - data = { - 'messages': [ - { - 'to': ['01000000001', '01000000002'], # 신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'subject': 'Sample 1', - 'text': 'MMS M6 타입 발송 예제입니다.', - 'imageId': sample1, - 'rcsOptions': { - 'mmsType': 'M6', # M3 ~ M6 (총 이미지 1M를 넘을 수 없음) - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - 'buttons': [ - {'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'} - # , { 'buttonType': 'ML', 'buttonName': '지도 위치 표시', 'latitude': '37.280342669603684', 'longitude': '127.11824209721874', 'label': '누리고', 'link': 'https://nurigo.net' } - # , { 'buttonType': 'MQ', 'buttonName': '지도 검색', 'link': 'https://nurigo.net', 'query': '(주)누리고' } - # , { 'buttonType': 'MR', 'buttonName': '나의 현재 위치' } - # , { 'buttonType': 'CA', 'buttonName': '캘린더 일정 생성', 'title': '제목', 'startTime': '2021-06-19T00:00:00.000Z', 'endTime': '2021-06-19T09:00:00.000Z', 'text': '메모' } - # , { 'buttonType': 'CL', 'buttonName': '텍스트 복사', 'text': '복사할 텍스트 내용' } - # , { 'buttonType': 'DL', 'buttonName': '전화 걸기', 'phone': '01012345678' } - # , { 'buttonType': 'MS', 'buttonName': '메시지 보내기', 'phone': '01012345678', 'text': '보낼 메시지 내용' } - ], - 'additionalBody': [ - { - 'imageId': sample2, - 'title': 'Sample 2', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - }, - { - 'imageId': sample3, - 'title': 'Sample 3', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - }, - { - 'imageId': sample4, - 'title': 'Sample 4', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - }, - { - 'imageId': sample5, - 'title': 'Sample 5', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - }, - { - 'imageId': sample6, - 'title': 'Sample 6', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - } - ] - } - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/rcs/send_rcs_mms_s3.py b/src/examples/modules/rcs/send_rcs_mms_s3.py deleted file mode 100644 index 765bf92..0000000 --- a/src/examples/modules/rcs/send_rcs_mms_s3.py +++ /dev/null @@ -1,59 +0,0 @@ -import json -from src.lib import message, storage - -# 한번 요청으로 1만건의 메시지 발송이 가능합니다. -if __name__ == '__main__': - res = storage.upload_rcs_image('images/sample1.png').json() - sample1 = res['fileId'] - res = storage.upload_rcs_image('images/sample2.png').json() - sample2 = res['fileId'] - res = storage.upload_rcs_image('images/sample3.png').json() - sample3 = res['fileId'] - - # 카드 3개 발송 예제(각 카드별 버튼 2개까지 가능) - data = { - 'messages': [ - { - 'to': ['01000000001', '01000000002'], # 신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'subject': 'Sample 1', - 'text': 'MMS S3 타입 발송 예제입니다.', - 'imageId': sample1, - 'rcsOptions': { - 'mmsType': 'S3', # S3 ~ S6 (총 이미지 1M를 넘을 수 없음) - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - 'buttons': [ - {'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'} - # , { 'buttonType': 'ML', 'buttonName': '지도 위치 표시', 'latitude': '37.280342669603684', 'longitude': '127.11824209721874', 'label': '누리고', 'link': 'https://nurigo.net' } - # , { 'buttonType': 'MQ', 'buttonName': '지도 검색', 'link': 'https://nurigo.net', 'query': '(주)누리고' } - # , { 'buttonType': 'MR', 'buttonName': '나의 현재 위치' } - # , { 'buttonType': 'CA', 'buttonName': '캘린더 일정 생성', 'title': '제목', 'startTime': '2021-06-19T00:00:00.000Z', 'endTime': '2021-06-19T09:00:00.000Z', 'text': '메모' } - # , { 'buttonType': 'CL', 'buttonName': '텍스트 복사', 'text': '복사할 텍스트 내용' } - # , { 'buttonType': 'DL', 'buttonName': '전화 걸기', 'phone': '01012345678' } - # , { 'buttonType': 'MS', 'buttonName': '메시지 보내기', 'phone': '01012345678', 'text': '보낼 메시지 내용' } - ], - 'additionalBody': [ - { - 'imageId': sample2, - 'title': 'Sample 2', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - }, - { - 'imageId': sample3, - 'title': 'Sample 3', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - } - ] - } - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/rcs/send_rcs_mms_s6.py b/src/examples/modules/rcs/send_rcs_mms_s6.py deleted file mode 100644 index 173c2e7..0000000 --- a/src/examples/modules/rcs/send_rcs_mms_s6.py +++ /dev/null @@ -1,86 +0,0 @@ -import json -from src.lib import message, storage - -# 한번 요청으로 1만건의 메시지 발송이 가능합니다. -if __name__ == '__main__': - res = storage.upload_rcs_image('images/sample1.png').json() - sample1 = res['fileId'] - res = storage.upload_rcs_image('images/sample2.png').json() - sample2 = res['fileId'] - res = storage.upload_rcs_image('images/sample3.png').json() - sample3 = res['fileId'] - res = storage.upload_rcs_image('images/sample4.png').json() - sample4 = res['fileId'] - res = storage.upload_rcs_image('images/sample5.png').json() - sample5 = res['fileId'] - res = storage.upload_rcs_image('images/sample6.png').json() - sample6 = res['fileId'] - - # 카드 6개 발송 예제(각 카드별 버튼 2개까지 가능) - data = { - 'messages': [ - { - 'to': ['01000000001', '01000000002'], # 신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'subject': 'Sample 1', - 'text': 'MMS S6 타입 발송 예제입니다.', - 'imageId': sample1, - 'rcsOptions': { - 'mmsType': 'S6', # S3 ~ S6 (총 이미지 1M를 넘을 수 없음) - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - 'buttons': [ - {'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'} - # , { 'buttonType': 'ML', 'buttonName': '지도 위치 표시', 'latitude': '37.280342669603684', 'longitude': '127.11824209721874', 'label': '누리고', 'link': 'https://nurigo.net' } - # , { 'buttonType': 'MQ', 'buttonName': '지도 검색', 'link': 'https://nurigo.net', 'query': '(주)누리고' } - # , { 'buttonType': 'MR', 'buttonName': '나의 현재 위치' } - # , { 'buttonType': 'CA', 'buttonName': '캘린더 일정 생성', 'title': '제목', 'startTime': '2021-06-19T00:00:00.000Z', 'endTime': '2021-06-19T09:00:00.000Z', 'text': '메모' } - # , { 'buttonType': 'CL', 'buttonName': '텍스트 복사', 'text': '복사할 텍스트 내용' } - # , { 'buttonType': 'DL', 'buttonName': '전화 걸기', 'phone': '01012345678' } - # , { 'buttonType': 'MS', 'buttonName': '메시지 보내기', 'phone': '01012345678', 'text': '보낼 메시지 내용' } - ], - 'additionalBody': [ - { - 'imageId': sample2, - 'title': 'Sample 2', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - }, - { - 'imageId': sample3, - 'title': 'Sample 3', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - }, - { - 'imageId': sample4, - 'title': 'Sample 4', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - }, - { - 'imageId': sample5, - 'title': 'Sample 5', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - }, - { - 'imageId': sample6, - 'title': 'Sample 6', - 'description': 'Description 설명', # 총합 1,300자 - 'buttons': [{'buttonType': 'WL', 'buttonName': '버튼1', 'link': 'https://nurigo.net'}, - {'buttonType': 'WL', 'buttonName': '버튼2', 'link': 'https://nurigo.net'}] - } - ] - } - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/rcs/send_rcs_sms.py b/src/examples/modules/rcs/send_rcs_sms.py deleted file mode 100644 index b4e63f5..0000000 --- a/src/examples/modules/rcs/send_rcs_sms.py +++ /dev/null @@ -1,39 +0,0 @@ -import json -from src.lib import message - -# 한번 요청으로 1만건의 메시지 발송이 가능합니다. -if __name__ == '__main__': - data = { - 'messages': [ - { - 'to': '01000000001', - 'from': '029302266', # 반드시 RCSBizCenter에 등록된 발신번호 입력 - 'text': 'RCS SMS를 발송합니다.', - 'rcsOptions': { - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - } - }, - { - 'to': ['01000000002', '01000000003'], # 신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'text': 'RCS SMS를 발송합니다.', - 'rcsOptions': { - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - 'buttons': [ - {'buttonType': 'WL', 'buttonName': '홈페이지 바로가기', 'link': 'https://nurigo.net'} - # , { 'buttonType': 'ML', 'buttonName': '지도 위치 표시', 'latitude': '37.280342669603684', 'longitude': '127.11824209721874', 'label': '누리고', 'link': 'https://nurigo.net' } - # , { 'buttonType': 'MQ', 'buttonName': '지도 검색', 'link': 'https://nurigo.net', 'query': '(주)누리고' } - # , { 'buttonType': 'MR', 'buttonName': '나의 현재 위치' } - # , { 'buttonType': 'CA', 'buttonName': '캘린더 일정 생성', 'title': '제목', 'startTime': '2021-06-19T00:00:00.000Z', 'endTime': '2021-06-19T09:00:00.000Z', 'text': '메모' } - # , { 'buttonType': 'CL', 'buttonName': '텍스트 복사', 'text': '복사할 텍스트 내용' } - # , { 'buttonType': 'DL', 'buttonName': '전화 걸기', 'phone': '01012345678' } - # , { 'buttonType': 'MS', 'buttonName': '메시지 보내기', 'phone': '01012345678', 'text': '보낼 메시지 내용' } - ] - } - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/rcs/send_rcs_tpl.py b/src/examples/modules/rcs/send_rcs_tpl.py deleted file mode 100644 index 54aa13d..0000000 --- a/src/examples/modules/rcs/send_rcs_tpl.py +++ /dev/null @@ -1,41 +0,0 @@ -import json -from src.lib import message - -# 한번 요청으로 1만건의 메시지 발송이 가능합니다. -if __name__ == '__main__': - data = { - 'messages': [ - { - 'to': '01000000001', - 'from': '029302266', # 반드시 RCSBizCenter에 등록된 발신번호 입력 - 'text': '템플릿 기반 RCS를 발송합니다.', - 'rcsOptions': { - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - 'templateId': 'RC01TP210727075536693B1z23GR7xWQ', - 'variables': { - '{{변수1}}': '변수1값', - '{{변수2}}': '변수2값', - '{{변수3}}': '변수3값' - } - } - }, - { - 'to': ['01000000002', '01000000003'], # 신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'text': '템플릿 기반 RCS를 발송합니다.', - 'rcsOptions': { - 'brandId': 'BR.iIr12HZe3j', # RCSBizCenter(https://www.rcsbizcenter.com/)에서 발급받은 브랜드ID 입력 - 'templateId': 'RC01TP210727075536693B1z23GR7xWQ', - 'variables': { - '{{변수1}}': '변수1값', - '{{변수2}}': '변수2값', - '{{변수3}}': '변수3값' - } - } - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/senderid/README.md b/src/examples/modules/senderid/README.md deleted file mode 100644 index 983f093..0000000 --- a/src/examples/modules/senderid/README.md +++ /dev/null @@ -1,18 +0,0 @@ -## 발신번호 등록 - -Step 1) 발신번호를 등록(생성)합니다. -create_number.py 파일의 phoneNumber 변수값을 발신번호로 사용할 전화번호로 입력해 주세요. - -## 발신번호 인증 - -Step 2) 전화번호 인증 - -* 해당 전화번호로 전화를 걸어 인증번호를 알려줍니다. -* 리턴되는 Transaction ID와 인증번호(Token)를 기록해두어 다음 Step에서 사용합니다. - -Step 3) 인증번호 확인 Trasaction ID 와 인증번호로 인증을 확인합니다. - -## 활성화된 발신번호 조회 - -Step 3 까지 진행했을 때 특별한 오류가 없었다면 활성화된 발신번호 목록에서 확인 가능합니다. - diff --git a/src/examples/modules/senderid/__init__.py b/src/examples/modules/senderid/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/examples/modules/senderid/create_number.py b/src/examples/modules/senderid/create_number.py deleted file mode 100644 index 00fc99b..0000000 --- a/src/examples/modules/senderid/create_number.py +++ /dev/null @@ -1,10 +0,0 @@ -import json -from src.lib import message - -''' -STEP 1) 발신번호 추가 예제 -다음 과정으로 request_voicecall.py 파일을 참고 해주세요. -''' -if __name__ == '__main__': - res = message.post("/senderid/v1/numbers", {'phoneNumber': '01000000001'}) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/senderid/get_active_numbers.py b/src/examples/modules/senderid/get_active_numbers.py deleted file mode 100644 index 5e25ae6..0000000 --- a/src/examples/modules/senderid/get_active_numbers.py +++ /dev/null @@ -1,9 +0,0 @@ -import json -from src.lib import message - -''' -활성화 된 발신번호 목록을 조회하는 예제 -''' -if __name__ == '__main__': - res = message.get("/senderid/v1/numbers/active") - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/senderid/request_voicecall.py b/src/examples/modules/senderid/request_voicecall.py deleted file mode 100644 index 8e14c02..0000000 --- a/src/examples/modules/senderid/request_voicecall.py +++ /dev/null @@ -1,28 +0,0 @@ -import json -from src.lib import message - -''' -Step 2) 인증번호 요청 -해당 전화번호로 전화를 걸어 인증번호를 알려줍니다. -리턴되는 Transaction ID와 인증번호(Token)를 기록해두어 다음 Step에서 사용합니다. -다음 과정으로 verify_token.py 파일을 참고 해주세요. -''' -if __name__ == '__main__': - # 인증받을 등록된 전화번호 입력 - phoneNumber = '01000000001' - - authInfo = { - 'authType': 'ARS', - 'extras': { - 'phoneNumber': phoneNumber - } - } - - headers = { - 'x-mfa-data': json.dumps(authInfo) - } - - res = message.put("/senderid/v1/numbers/%s/authenticate" % phoneNumber, {}, headers) - jsonData = res.json() - print(jsonData) - print('Transaction ID:', jsonData['mfa']['transactionId']) diff --git a/src/examples/modules/senderid/verify_token.py b/src/examples/modules/senderid/verify_token.py deleted file mode 100644 index e78e81e..0000000 --- a/src/examples/modules/senderid/verify_token.py +++ /dev/null @@ -1,33 +0,0 @@ -import json -from src.lib import message - -''' -Step 3) 인증번호 확인 -Step 2 과정에서 획득한 정보를 모두 입력하여 인증 받습니다. -이 과정이 모두 끝나면 정상적으로 발신번호를 이용하실 수 있습니다. -''' -if __name__ == '__main__': - # 전화번호 입력 - phoneNumber = '01000000001' - - # Transaction ID 입력 - transactionId = 'cc4f482bcf167f69f2b15fcfd044f509' - - # 음성으로 전달받은 인증번호 - token = '7894' - - authInfo = { - 'authType': 'ARS', - 'extras': { - 'phoneNumber': phoneNumber - }, - 'transactionId': transactionId, - 'token': token - } - - headers = { - 'x-mfa-data': json.dumps(authInfo) - } - - res = message.put("/senderid/v1/numbers/%s/authenticate" % phoneNumber, {}, headers) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/sms/__init__.py b/src/examples/modules/sms/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/examples/modules/sms/allow_duplicates.py b/src/examples/modules/sms/allow_duplicates.py deleted file mode 100644 index 65a2b64..0000000 --- a/src/examples/modules/sms/allow_duplicates.py +++ /dev/null @@ -1,25 +0,0 @@ -import json -from src.lib import message - -''' -중복 수신번호 허용 예제 -''' -if __name__ == '__main__': - data = { - 'allowDuplicates': True, # 수신번호 중복 입력을 허용합니다. - 'messages': [ - { - 'to': '01000000001', - 'from': '029302266', - 'text': '동일한 수신번호로 발송 #1' - }, - { - 'to': '01000000001', - 'from': '029302266', - 'text': '동일한 수신번호로 발송 #2' # 동일한 내용 입력 시 수신된 문자는 하나로 보여질 수 있습니다. - }, - # 한 번 요청으로 최대 10,000건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/sms/send_global_sms.py b/src/examples/modules/sms/send_global_sms.py deleted file mode 100644 index 2eb094a..0000000 --- a/src/examples/modules/sms/send_global_sms.py +++ /dev/null @@ -1,17 +0,0 @@ -import json -from src.lib import message - -''' -해외 문자발송 예제 -''' -if __name__ == '__main__': - data = { - 'message': { - 'to': '', # 보내실 수신번호를 국가번호를 제외하여 입력해주세요. - 'from': '029302266', # 반드시 사용자 개인이 등록한 번호로만 발송 가능합니다. - 'text': 'NURIGO Verification Code: 1234', # 기본적으로 NURIGO, 1234 등을 변경하여 발송하시는 것을 권장드립니다. - 'country': '' # 보내실 국가 코드를 입력해주세요, 예) 미국, 캐나다 -> '1', 중국 -> '86', 일본 -> '81'... - }, - } - res = message.send_one(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/sms/send_lms.py b/src/examples/modules/sms/send_lms.py deleted file mode 100644 index bbdb074..0000000 --- a/src/examples/modules/sms/send_lms.py +++ /dev/null @@ -1,41 +0,0 @@ -import json -from src.lib import message - -# 한번 요청으로 1만건의 메시지 발송이 가능합니다. -if __name__ == '__main__': - data = { - 'messages': [ - { - 'to': '01000000001', - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이상 입력되면 자동으로 LMS타입의 문자메시지가 발송됩니다. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ' - }, - { - 'to': '01000000002', - 'from': '029302266', - 'subject': 'LMS 제목', # 제목을 지정할 수 있습니다. - 'text': '한글 45자, 영자 90자 이상 입력되면 자동으로 LMS타입의 문자메시지가 발송됩니다. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ' - }, - { - 'type': 'LMS', # 타입을 명시할 수 있습니다. - 'to': '01000000003', - 'from': '029302266', - 'text': '내용이 짧아도 LMS로 발송됩니다.' - }, - { - 'to': '01000000004', - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이하는 자동으로 SMS타입의 문자가 발송됩니다.' - }, - { - 'to': ['01000000005', '01000000006'], # 수신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'subject': 'LMS 제목', - 'text': '한글 45자, 영자 90자 이상 입력되면 자동으로 LMS타입의 문자메시지가 발송됩니다. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ' - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/sms/send_mms.py b/src/examples/modules/sms/send_mms.py deleted file mode 100644 index a16a79a..0000000 --- a/src/examples/modules/sms/send_mms.py +++ /dev/null @@ -1,36 +0,0 @@ -import json -from src.lib import message, storage - -# 한번 요청으로 1만건의 메시지 발송이 가능합니다. -if __name__ == '__main__': - res = storage.upload_image('../testImage.jpg').json() - fileId = res['fileId'] - data = { - 'messages': [ - { - 'to': '01000000001', - 'from': '029302266', - 'subject': 'MMS 제목', - 'imageId': fileId, - 'text': '이미지 아이디가 입력되면 MMS로 발송됩니다.' - }, - { - 'to': '01000000002', - 'from': '029302266', - 'subject': 'MMS 제목', - 'imageId': fileId, - 'text': '동일한 이미지 아이디가 입력되면 동일한 이미지가 MMS로 발송됩니다.' - }, - { - 'to': ['01000000003', '01000000004'], # array로 입력하면 여러명에게 동일한 내용으로 발송됩니다. - 'from': '029302266', - 'subject': 'MMS 제목', - 'imageId': fileId, - 'text': '동일한 이미지 아이디가 입력되면 동일한 이미지가 MMS로 발송됩니다.' - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/sms/send_sms.py b/src/examples/modules/sms/send_sms.py deleted file mode 100644 index 4fce702..0000000 --- a/src/examples/modules/sms/send_sms.py +++ /dev/null @@ -1,40 +0,0 @@ -import json -from src.lib import message - -# 한번 요청으로 1만건의 메시지 발송이 가능합니다. -if __name__ == '__main__': - data = { - 'messages': [ - { - 'to': '01000000001', - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이하 입력되면 자동으로 SMS타입의 메시지가 추가됩니다.' - }, - { - 'to': '01000000002', - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이상 입력되면 자동으로 LMS타입의 문자메시자가 발송됩니다. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ' - }, - { - 'type': 'SMS', - 'to': '01000000003', - 'from': '029302266', - 'text': 'SMS 타입에 한글 45자, 영자 90자 이상 입력되면 오류가 발생합니다. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ' - }, - { - 'to': ['01000000004', '01000000005'], # 수신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이하 입력되면 자동으로 SMS타입의 메시지가 발송됩니다.' - }, - { - 'country': '1', # 미국(1), 일본(81), 중국(86) 등 국가번호 입력 - 'to': '01000000006', # 수신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이하 입력되면 자동으로 SMS타입의 메시지가 발송됩니다.' - } - # ... - # 1만건까지 추가 가능 - ] - } - res = message.send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/storage/__init__.py b/src/examples/modules/storage/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/examples/modules/storage/delete_image.py b/src/examples/modules/storage/delete_image.py deleted file mode 100644 index b906537..0000000 --- a/src/examples/modules/storage/delete_image.py +++ /dev/null @@ -1,12 +0,0 @@ -import requests -import json -from src.lib import auth, config - -''' -이미지 파일 삭제 예제 -''' -if __name__ == '__main__': - # [FILE_ID]에 fileId를 넣어 주세요. - res = requests.delete(config.get_url('/storage/v1/files/[FILE_ID]'), - headers=auth.get_headers(config.api_key, config.api_secret)) - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/storage/get_image_info.py b/src/examples/modules/storage/get_image_info.py deleted file mode 100644 index d8db776..0000000 --- a/src/examples/modules/storage/get_image_info.py +++ /dev/null @@ -1,12 +0,0 @@ -import requests -import json -from src.lib import auth, config - -''' -이미지 파일 단 건 조회 예제 -''' -if __name__ == '__main__': - # [FILE_ID] 에 fileId를 넣어주세요 - res = requests.get(config.get_url('/storage/v1/files/[FILE_ID]'), - headers=auth.get_headers(config.api_key, config.api_secret)) - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/storage/get_image_list.py b/src/examples/modules/storage/get_image_list.py deleted file mode 100644 index 6d94a24..0000000 --- a/src/examples/modules/storage/get_image_list.py +++ /dev/null @@ -1,10 +0,0 @@ -import requests -import json -from src.lib import auth, config - -''' -이미지 파일 리스트 조회 예제 -''' -if __name__ == '__main__': - res = requests.get(config.get_url('/storage/v1/files'), headers=auth.get_headers(config.api_key, config.api_secret)) - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/storage/testImage.jpg b/src/examples/modules/storage/testImage.jpg deleted file mode 100644 index 61e20b663d2825d7dd258ae15cf3f6a92c8f9558..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12664 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 BtlIzp diff --git a/src/examples/modules/storage/upload_image.py b/src/examples/modules/storage/upload_image.py deleted file mode 100644 index b2c91c4..0000000 --- a/src/examples/modules/storage/upload_image.py +++ /dev/null @@ -1,10 +0,0 @@ -import json -import src.lib.storage as storage - -''' -이미지 파일 업로드 예제 -''' -if __name__ == '__main__': - # 이미지를 바꾸시려면 testImage.jpg 대신 사용하실 이미지가 있는 파일 경로를 넣어주세요 - res = storage.upload_image('testImage.jpg') - print(json.dumps(json.loads(res.text), indent=2, ensure_ascii=False)) diff --git a/src/examples/modules/testImage.jpg b/src/examples/modules/testImage.jpg deleted file mode 100644 index d997e8ec3d4cca15bd35fe9b49ee2c164de57af5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 61963 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 Z>XMAtfnCpB%zFz^a{m8}cIwaZ{{tDniP8W7 diff --git a/src/examples/scripts/README.md b/src/examples/scripts/README.md deleted file mode 100644 index 2303cb4..0000000 --- a/src/examples/scripts/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Python Script 예제 - -### 발송 관련 예제 - -``` -send_messages.py -> 메시지 발송 예제 -set_scheduled_message -> 메시지 예약발송 예제 -``` - -### 조회 관련 예제 - -``` -get_balances.py -> 잔액 조회 예제 -get_messages.py -> 메시지 리스트 조회(query parameter를 통해 단 건 조회 가능) -``` - -각 기능 별 상세한 설명은 [개발 문서 페이지](https://docs.solapi.com/api-reference/overview) 를 참고하세요. \ No newline at end of file diff --git a/src/examples/scripts/get_balances.py b/src/examples/scripts/get_balances.py deleted file mode 100644 index e325c41..0000000 --- a/src/examples/scripts/get_balances.py +++ /dev/null @@ -1,61 +0,0 @@ -import json -import time -import datetime -import uuid -import hmac -import hashlib -import requests - -# 아래 값은 필요시 수정 -protocol = 'https' -domain = 'api.solapi.com' -prefix = '' - - -def unique_id(): - return str(uuid.uuid1().hex) - - -def get_iso_datetime(): - utc_offset_sec = time.altzone if time.localtime().tm_isdst else time.timezone - utc_offset = datetime.timedelta(seconds=-utc_offset_sec) - return datetime.datetime.now().replace(tzinfo=datetime.timezone(offset=utc_offset)).isoformat() - - -def get_signature(key, msg): - return hmac.new(key.encode(), msg.encode(), hashlib.sha256).hexdigest() - - -def get_headers(api_key, api_secret): - date = get_iso_datetime() - salt = unique_id() - combined_string = date + salt - - return { - 'Authorization': 'HMAC-SHA256 ApiKey=' + api_key + ', Date=' + date + ', salt=' + salt + ', signature=' + - get_signature(api_secret, combined_string), - 'Content-Type': 'application/json; charset=utf-8' - } - - -def get_url(path): - url = '%s://%s' % (protocol, domain) - if prefix != '': - url = url + prefix - url = url + path - return url - - -''' -잔액 조회 예제 -''' -if __name__ == '__main__': - # 반드시 관리 콘솔 내 발급 받으신 API KEY, API SECRET KEY를 입력해주세요. - api_key = 'INPUT YOUR API KEY' - api_secret = 'INPUT YOUR SECRET KEY' - - res = requests.get(get_url('/cash/v1/balance'), - headers=get_headers(api_key, api_secret)) - - # 실제 충전금액 및 포인트는 response 내 각각 balance, point 데이터로 확인할 수 있습니다. - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/scripts/get_messages.py b/src/examples/scripts/get_messages.py deleted file mode 100644 index fec72ec..0000000 --- a/src/examples/scripts/get_messages.py +++ /dev/null @@ -1,105 +0,0 @@ -import json -import time -import datetime -import uuid -import hmac -import hashlib -import requests - -# 아래 값은 필요시 수정 -protocol = 'https' -domain = 'api.solapi.com' -prefix = '' - - -def unique_id(): - return str(uuid.uuid1().hex) - - -def get_iso_datetime(): - utc_offset_sec = time.altzone if time.localtime().tm_isdst else time.timezone - utc_offset = datetime.timedelta(seconds=-utc_offset_sec) - return datetime.datetime.now().replace(tzinfo=datetime.timezone(offset=utc_offset)).isoformat() - - -def get_signature(key, msg): - return hmac.new(key.encode(), msg.encode(), hashlib.sha256).hexdigest() - - -def get_headers(api_key, api_secret): - date = get_iso_datetime() - salt = unique_id() - combined_string = date + salt - - return { - 'Authorization': 'HMAC-SHA256 ApiKey=' + api_key + ', Date=' + date + ', salt=' + salt + ', signature=' + - get_signature(api_secret, combined_string), - 'Content-Type': 'application/json; charset=utf-8' - } - - -def get_url(path): - url = '%s://%s' % (protocol, domain) - if prefix != '': - url = url + prefix - url = url + path - return url - - -''' -메시지 조회 예제(알림톡, 일반 문자 등 모두 포함) -''' -if __name__ == '__main__': - # 반드시 관리 콘솔 내 발급 받으신 API KEY, API SECRET KEY를 입력해주세요 - api_key = 'INPUT YOUR API KEY' - api_secret = 'INPUT YOUR SECRET KEY' - - res = requests.get(get_url('/messages/v4/list'), - headers=get_headers(api_key, api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # limit - res = requests.get(get_url('/messages/v4/list?limit=10'), - headers=get_headers(api_key, api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # messageId - res = requests.get(get_url('/messages/v4/list?messageId=XXXXXXX'), - headers=get_headers(api_key, api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # groupId - res = requests.get(get_url('/messages/v4/list?groupId=XXXXXXX'), - headers=get_headers(api_key, api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # 수신번호로 조회 - res = requests.get(get_url('/messages/v4/list?to=01000000001'), - headers=get_headers(api_key, api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # 발신번호로 조회 - res = requests.get(get_url('/messages/v4/list?from=029302266'), - headers=get_headers(api_key, api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # 메시지타입으로 조회 - res = requests.get(get_url('/messages/v4/list?type=SMS'), - headers=get_headers(api_key, api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # 메시지 상태 코드로 조회 - res = requests.get(get_url('/messages/v4/list?statusCode=4000'), - headers=get_headers(api_key, api_secret)) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) - - # 페이지 처리 - page1 = requests.get(get_url('/messages/v4/list?limit=5'), - headers=get_headers(api_key, api_secret)).json() - print(json.dumps(page1, indent=2, ensure_ascii=False)) - if 'nextKey' in res: - # 읽어올 데이터가 더 있음 - startKey = res['nextKey'] - page2 = requests.get(get_url('/messages/v4/list?limit=5&startKey=%s' % startKey), - headers=get_headers(api_key, api_secret)).json() - print(json.dumps(page2, indent=2, ensure_ascii=False)) diff --git a/src/examples/scripts/send_messages.py b/src/examples/scripts/send_messages.py deleted file mode 100644 index 0ecfcb9..0000000 --- a/src/examples/scripts/send_messages.py +++ /dev/null @@ -1,120 +0,0 @@ -import json -import time -import datetime -import uuid -import hmac -import hashlib -import requests -import platform - -# 아래 값은 필요시 수정 -protocol = 'https' -domain = 'api.solapi.com' -prefix = '' - - -def unique_id(): - return str(uuid.uuid1().hex) - - -def get_iso_datetime(): - utc_offset_sec = time.altzone if time.localtime().tm_isdst else time.timezone - utc_offset = datetime.timedelta(seconds=-utc_offset_sec) - return datetime.datetime.now().replace(tzinfo=datetime.timezone(offset=utc_offset)).isoformat() - - -def get_signature(key, msg): - return hmac.new(key.encode(), msg.encode(), hashlib.sha256).hexdigest() - - -def get_headers(api_key, api_secret): - date = get_iso_datetime() - salt = unique_id() - combined_string = date + salt - - return { - 'Authorization': 'HMAC-SHA256 ApiKey=' + api_key + ', Date=' + date + ', salt=' + salt + ', signature=' + - get_signature(api_secret, combined_string), - 'Content-Type': 'application/json; charset=utf-8' - } - - -def get_url(path): - url = '%s://%s' % (protocol, domain) - if prefix != '': - url = url + prefix - url = url + path - return url - - -def send_many(parameter): - # 반드시 관리 콘솔 내 발급 받으신 API KEY, API SECRET KEY를 입력해주세요 - api_key = 'INPUT YOUR API KEY' - api_secret = 'INPUT YOUR SECRET KEY' - parameter['agent'] = { - 'sdkVersion': 'python/4.2.0', - 'osPlatform': platform.platform() + " | " + platform.python_version() - } - - return requests.post(get_url('/messages/v4/send-many'), headers=get_headers(api_key, api_secret), json=parameter) - - -''' -한번 요청으로 1만건의 메시지 발송이 가능합니다. -해당 파일을 통해 별도 import 없이 발송 테스트가 가능합니다. -from 데이터의 경우 반드시 관리 콘솔 내 등록하신 발신번호를 넣으셔야 정상 발송 가능합니다. -''' -if __name__ == '__main__': - data = { - 'messages': [ - { - 'to': '01000000001', - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이하 입력되면 자동으로 SMS타입의 메시지가 추가됩니다.' - }, - { - 'to': '01000000002', - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이상 입력되면 자동으로 LMS타입의 문자메시자가 발송됩니다. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ' - }, - { - 'type': 'SMS', - 'to': '01000000003', - 'from': '029302266', - 'text': 'SMS 타입에 한글 45자, 영자 90자 이상 입력되면 오류가 발생합니다. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ' - }, - { - 'to': ['01000000004', '01000000005'], # 수신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이하 입력되면 자동으로 SMS타입의 메시지가 발송됩니다.' - }, - # 해외발송 - { - 'country': '1', # 미국(1), 일본(81), 중국(86) 등 국가번호 입력, 국가 번호에 띄어쓰기가 있는 경우 붙여서 기입해야 합니다. 예) +1 809 -> '1809' - 'to': '01000000006', # 수신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이하 입력되면 자동으로 SMS타입의 메시지가 발송됩니다.' - }, - # 알림톡 발송 - { - 'to': '01000000004', - 'from': '029302266', - 'kakaoOptions': { - 'pfId': 'KA01PF200323182344986oTFz9CIabcx', - 'templateId': 'KA01TP200323182345741y9yF20aabcx', - # 변수: 값 형식으로 모든 변수에 대한 변수값 입력 - 'variables': { - '#{변수1}': '변수1의 값', - '#{변수2}': '변수2의 값', - '#{버튼링크1}': '버튼링크1의 값', - '#{버튼링크2}': '버튼링크2의 값', - '#{강조문구}': '강조문구의 값' - } - } - } - # ... - # 1만건까지 추가 가능 - ] - } - res = send_many(data) - print(json.dumps(res.json(), indent=2, ensure_ascii=False)) diff --git a/src/examples/scripts/set_scheduled_message.py b/src/examples/scripts/set_scheduled_message.py deleted file mode 100644 index 10679fd..0000000 --- a/src/examples/scripts/set_scheduled_message.py +++ /dev/null @@ -1,151 +0,0 @@ -import json -import time -import datetime -import uuid -import hmac -import hashlib -import requests -import platform - -# 아래 값은 필요시 수정 -protocol = 'https' -domain = 'api.solapi.com' -prefix = '' - -# 반드시 관리 콘솔 내 발급 받으신 API KEY, API SECRET KEY를 입력해주세요 -api_key = 'INPUT YOUR API KEY' -api_secret = 'INPUT YOUR SECRET KEY' - - -def unique_id(): - return str(uuid.uuid1().hex) - - -def get_iso_datetime(): - utc_offset_sec = time.altzone if time.localtime().tm_isdst else time.timezone - utc_offset = datetime.timedelta(seconds=-utc_offset_sec) - return datetime.datetime.now().replace(tzinfo=datetime.timezone(offset=utc_offset)).isoformat() - - -def get_signature(key, msg): - return hmac.new(key.encode(), msg.encode(), hashlib.sha256).hexdigest() - - -def get_headers(api_key, api_secret): - date = get_iso_datetime() - salt = unique_id() - combined_string = date + salt - - return { - 'Authorization': 'HMAC-SHA256 ApiKey=' + api_key + ', Date=' + date + ', salt=' + salt + ', signature=' + - get_signature(api_secret, combined_string), - 'Content-Type': 'application/json; charset=utf-8' - } - - -def get_url(path): - url = '%s://%s' % (protocol, domain) - if prefix != '': - url = url + prefix - url = url + path - return url - - -def get(url): - return requests.get(get_url(url), headers=get_headers(api_key, api_secret)) - - -def post(url, parameter): - return requests.post(get_url(url), headers=get_headers(api_key, api_secret), json=parameter) - - -def put(url, parameter): - return requests.put(get_url(url), headers=get_headers(api_key, api_secret), json=parameter) - - -def delete(url): - return requests.delete(get_url(url), headers=get_headers(api_key, api_secret)) - - -''' -예약발송 예제 -''' -if __name__ == '__main__': - # STEP 1 그룹 추가 - addGroupResponse = post('/messages/v4/groups', parameter={ - 'sdkVersion': 'python/4.2.0', - 'osPlatform': platform.platform() + " | " + platform.python_version() - }) - groupResponse: dict = addGroupResponse.json() - groupId = groupResponse['groupId'] - - # STEP 2 발송할 메시지 데이터 추가 - messagesDict = { - 'messages': [ - { - 'to': '01000000001', - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이하 입력되면 자동으로 SMS타입의 메시지가 추가됩니다.' - }, - { - 'to': '01000000002', - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이상 입력되면 자동으로 LMS타입의 문자메시자가 발송됩니다. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ' - }, - { - 'type': 'SMS', - 'to': '01000000003', - 'from': '029302266', - 'text': 'SMS 타입에 한글 45자, 영자 90자 이상 입력되면 오류가 발생합니다. 0123456789 ABCDEFGHIJKLMNOPQRSTUVWXYZ' - }, - { - 'to': ['01000000004', '01000000005'], # 수신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이하 입력되면 자동으로 SMS타입의 메시지가 발송됩니다.' - }, - # 해외발송 - { - 'country': '1', # 미국(1), 일본(81), 중국(86) 등 국가번호 입력, 국가 번호에 띄어쓰기가 있는 경우 붙여서 기입해야 합니다. 예) +1 809 -> '1809' - 'to': '01000000006', # 수신번호를 array로 입력하면 같은 내용을 여러명에게 보낼 수 있습니다. - 'from': '029302266', - 'text': '한글 45자, 영자 90자 이하 입력되면 자동으로 SMS타입의 메시지가 발송됩니다.' - }, - # 알림톡 발송 - { - 'to': '01000000004', - 'from': '029302266', - 'kakaoOptions': { - 'pfId': 'KA01PF200323182344986oTFz9CIabcx', - 'templateId': 'KA01TP200323182345741y9yF20aabcx', - # 변수: 값 형식으로 모든 변수에 대한 변수값 입력 - 'variables': { - '#{변수1}': '변수1의 값', - '#{변수2}': '변수2의 값', - '#{버튼링크1}': '버튼링크1의 값', - '#{버튼링크2}': '버튼링크2의 값', - '#{강조문구}': '강조문구의 값' - } - } - } - # ... - # 1만건까지 추가 가능 - ] - } - put('/messages/v4/groups/%s/messages' % groupId, parameter=messagesDict) - - # STEP 3 예약 발송일 등록 - utc_offset_sec = time.altzone if time.localtime().tm_isdst else time.timezone - utc_offset = datetime.timedelta(seconds=-utc_offset_sec) - - # 희망하시는 예약 날짜를 넣어주세요 년 - # datetime(년, 월, 일, 시, 분, 초) - scheduledDate = datetime.datetime(2022, 2, 8, 15, 0, 0).replace( - tzinfo=datetime.timezone(offset=utc_offset)).isoformat() - scheduledDateDict = { - 'scheduledDate': scheduledDate - } - setScheduledGroupResponse = post('/messages/v4/groups/%s/schedule' % groupId, parameter=scheduledDateDict) - print(json.dumps(json.loads(setScheduledGroupResponse.text), indent=2, ensure_ascii=False)) - - # 예약 발송 취소를 희망하실 경우 아래 코드를 추가해주세요. - # delete('/messages/v4/groups/%s/schedule' % groupId) diff --git a/src/lib/__init__.py b/src/lib/__init__.py deleted file mode 100644 index 127027b..0000000 --- a/src/lib/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .message import * -from .config import * -from .auth import * -from .storage import * - -__all__ = ['message', 'config', 'auth', 'storage'] diff --git a/src/lib/auth.py b/src/lib/auth.py deleted file mode 100644 index 738bbfc..0000000 --- a/src/lib/auth.py +++ /dev/null @@ -1,30 +0,0 @@ -import time -import datetime -import uuid -import hmac -import hashlib - - -def unique_id(): - return str(uuid.uuid1().hex) - - -def get_iso_datetime(): - utc_offset_sec = time.altzone if time.localtime().tm_isdst else time.timezone - utc_offset = datetime.timedelta(seconds=-utc_offset_sec) - return datetime.datetime.now().replace(tzinfo=datetime.timezone(offset=utc_offset)).isoformat() - - -def get_signature(key='', msg=''): - return hmac.new(key.encode(), msg.encode(), hashlib.sha256).hexdigest() - - -def get_headers(api_key='', api_secret_key=''): - date = get_iso_datetime() - salt = unique_id() - data = date + salt - return { - 'Authorization': 'HMAC-SHA256 ApiKey=' + api_key + ', Date=' + date + ', salt=' + salt + ', signature=' + - get_signature(api_secret_key, data), - 'Content-Type': 'application/json; charset=utf-8' - } diff --git a/src/lib/config-dist.ini b/src/lib/config-dist.ini deleted file mode 100644 index 46aab27..0000000 --- a/src/lib/config-dist.ini +++ /dev/null @@ -1,9 +0,0 @@ -[AUTH] -# 계정의 API Key와 API Secret을 입력해주세요 -api_key = [API KEY] -api_secret = [API SECRET] - -[SERVER] -domain = api.solapi.com -protocol = https -prefix = diff --git a/src/lib/config.py b/src/lib/config.py deleted file mode 100644 index 4cb8663..0000000 --- a/src/lib/config.py +++ /dev/null @@ -1,20 +0,0 @@ -import os -import configparser - -lib_dir = os.path.dirname(__file__) -env_config = configparser.ConfigParser() -env_config.read(lib_dir + '/config.ini') - -api_key = env_config['AUTH']['api_key'] -api_secret = env_config['AUTH']['api_secret'] -protocol = env_config['SERVER']['protocol'] -domain = env_config['SERVER']['domain'] -prefix = env_config['SERVER']['prefix'] and env_config['SERVER']['prefix'] or '' - - -def get_url(path): - url = '%s://%s' % (protocol, domain) - if prefix != '': - url = url + prefix - url = url + path - return url diff --git a/src/lib/message.py b/src/lib/message.py deleted file mode 100644 index 83b64fc..0000000 --- a/src/lib/message.py +++ /dev/null @@ -1,48 +0,0 @@ -import requests -import platform -import src.lib.auth as auth -import src.lib.config as config - -default_agent = { - 'sdkVersion': 'python/4.2.0', - 'osPlatform': platform.platform() + " | " + platform.python_version() -} - - -def send_many(data): - data['agent'] = default_agent - return requests.post(config.get_url('/messages/v4/send-many'), - headers=auth.get_headers(config.api_key, config.api_secret), json=data) - - -def send_one(data): - data['agent'] = default_agent - return requests.post(config.get_url('/messages/v4/send'), - headers=auth.get_headers(config.api_key, config.api_secret), - json=data) - - -def post(path, data): - return requests.post(config.get_url(path), headers=auth.get_headers(config.api_key, config.api_secret), json=data) - - -def put(path, data, headers=None): - if headers is None: - headers = {} - headers.update(auth.get_headers(config.api_key, config.api_secret)) - return requests.put(config.get_url(path), headers=headers, json=data) - - -def get(path, headers=None): - if headers is None: - headers = {} - headers.update(auth.get_headers(config.api_key, config.api_secret)) - return requests.get(config.get_url(path), headers=headers) - - -def delete(path, data): - if data is None: - return requests.delete(config.get_url(path), headers=auth.get_headers(config.api_key, config.api_secret)) - else: - return requests.delete(config.get_url(path), headers=auth.get_headers(config.api_key, config.api_secret), - json=data) diff --git a/src/lib/storage.py b/src/lib/storage.py deleted file mode 100644 index 7837508..0000000 --- a/src/lib/storage.py +++ /dev/null @@ -1,37 +0,0 @@ -import requests -import base64 -import src.lib.config as config -import src.lib.auth as auth - - -def upload_image(path): - with open(path, "rb") as image_file: - encoded_string = base64.b64encode(image_file.read()) - data = { - 'file': str(encoded_string)[2:-1], - 'type': 'MMS' - } - headers = auth.get_headers(config.api_key, config.api_secret) - return requests.post(config.get_url('/storage/v1/files'), headers=headers, json=data) - - -def upload_rcs_image(path): - with open(path, "rb") as image_file: - encoded_string = base64.b64encode(image_file.read()) - data = { - 'file': str(encoded_string)[2:-1], - 'type': 'RCS' - } - headers = auth.get_headers(config.api_key, config.api_secret) - return requests.post(config.get_url('/storage/v1/files'), headers=headers, json=data) - - -def upload_kakao_image(path): - with open(path, "rb") as image_file: - encoded_string = base64.b64encode(image_file.read()) - data = { - 'file': str(encoded_string)[2:-1], - 'type': 'KAKAO' - } - headers = auth.get_headers(config.api_key, config.api_secret) - return requests.post(config.get_url('/storage/v1/files'), headers=headers, json=data) diff --git a/src/__init__.py b/src/solapi/__init__.py similarity index 100% rename from src/__init__.py rename to src/solapi/__init__.py diff --git a/src/examples/__init__.py b/src/solapi/lib/__init__.py similarity index 100% rename from src/examples/__init__.py rename to src/solapi/lib/__init__.py diff --git a/src/solapi/lib/authenticator.py b/src/solapi/lib/authenticator.py new file mode 100644 index 0000000..0804822 --- /dev/null +++ b/src/solapi/lib/authenticator.py @@ -0,0 +1,40 @@ +import datetime +import hashlib +import hmac +import time +import uuid + + +def unique_id(): + return str(uuid.uuid1().hex) + + +def get_iso_datetime(): + utc_offset_sec = time.altzone if time.localtime().tm_isdst else time.timezone + utc_offset = datetime.timedelta(seconds=-utc_offset_sec) + return ( + datetime.datetime.now() + .replace(tzinfo=datetime.timezone(offset=utc_offset)) + .isoformat() + ) + + +def get_signature(key="", msg=""): + return hmac.new(key.encode(), msg.encode(), hashlib.sha256).hexdigest() + + +def get_headers(api_key="", api_secret_key=""): + date = get_iso_datetime() + salt = unique_id() + data = date + salt + return { + "Authorization": "HMAC-SHA256 ApiKey=" + + api_key + + ", Date=" + + date + + ", salt=" + + salt + + ", signature=" + + get_signature(api_secret_key, data), + "Content-Type": "application/json; charset=utf-8", + } \ No newline at end of file diff --git a/src/solapi/lib/fetcher.py b/src/solapi/lib/fetcher.py new file mode 100644 index 0000000..f56f261 --- /dev/null +++ b/src/solapi/lib/fetcher.py @@ -0,0 +1,16 @@ +from typing import TypeVar + +# from authenticator import get_headers + + +def get_url(path): + url: str = "https://api.solapi.com" + return url + path + + +T = TypeVar("T") +R = TypeVar("R") + + +def fetch(request, data: T) -> R: + return "" diff --git a/src/solapi/lib/string_date_transfer.py b/src/solapi/lib/string_date_transfer.py new file mode 100644 index 0000000..45a15a4 --- /dev/null +++ b/src/solapi/lib/string_date_transfer.py @@ -0,0 +1,99 @@ +import re +from datetime import datetime +from typing import Union + + +class InvalidDateError(Exception): + """날짜 형식이 유효하지 않을 때 발생하는 예외""" + + def __init__(self, message="Invalid date format", *args): + self.message = message + super().__init__(message, *args) + + def __str__(self): + return self.message + + +def format_iso(date: datetime) -> str: + """ + datetime 객체를 ISO 8601 형식의 문자열로 변환 + + Args: + date: 변환할 datetime 객체 + + Returns: + ISO 8601 형식의 문자열 (예: '2023-01-01T12:00:00Z') + """ + return date.isoformat() + + +def parse_iso(date_string: str) -> datetime: + """ + ISO 8601 형식의 문자열을 datetime 객체로 변환 + + Args: + date_string: 변환할 ISO 8601 형식의 문자열 + + Returns: + 변환된 datetime 객체 + + Raises: + InvalidDateError: 날짜 형식이 유효하지 않을 경우 + """ + try: + # ISO 8601 형식 검증을 위한 간단한 정규식 + iso_pattern = re.compile( + r"^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?(\.\d+)?(([+-]\d{2}:\d{2})|Z)?)?$" + ) + if not iso_pattern.match(date_string): + raise ValueError("Invalid ISO format") + + # 타임존 정보가 없는 경우 처리 + if ( + "Z" not in date_string + and "+" not in date_string + and "-" not in date_string[10:] + ): + date_string += "Z" + + return datetime.fromisoformat(date_string.replace("Z", "+00:00")) + except ValueError as e: + raise InvalidDateError("Invalid Date") from e + + +def string_date_transfer(value: Union[str, datetime]) -> datetime: + """ + 일반 문자열 날짜가 있을 경우 datetime 타입으로 변환해주는 함수 + + Args: + value: 일반 문자열 날짜 또는 datetime 타입의 날짜 + + Returns: + datetime 객체 + + Raises: + InvalidDateError: 날짜 형식이 유효하지 않을 경우 + """ + if isinstance(value, str): + try: + value = parse_iso(value) + except Exception as e: + raise InvalidDateError("Invalid Date") from e + + return value + + +def transfer(value: Union[str, datetime]) -> str: + """ + string_date_transfer와 format_iso를 한번에 실행하는 함수 + + Args: + value: datetime 타입의 날짜 또는 ISO 형식의 문자열 + + Returns: + ISO 8601 형식의 문자열 + + Raises: + InvalidDateError: 날짜 형식이 유효하지 않을 경우 + """ + return format_iso(string_date_transfer(value)) diff --git a/src/examples/modules/__init__.py b/src/solapi/requests/__init__.py similarity index 100% rename from src/examples/modules/__init__.py rename to src/solapi/requests/__init__.py diff --git a/src/solapi/requests/message_request.py b/src/solapi/requests/message_request.py new file mode 100644 index 0000000..ff93ad8 --- /dev/null +++ b/src/solapi/requests/message_request.py @@ -0,0 +1,15 @@ +import platform +import sys +from typing import NotRequired, TypedDict + + +class DefaultAgentType(TypedDict): + sdkVersion: str + osPlatform: str + appId: NotRequired[str] + + +default_agent: DefaultAgentType = { + "sdkVersion": "python/5.0.0", + "osPlatform": f"{platform.system().lower()} | {sys.version.split()[0]}", +} \ No newline at end of file diff --git a/src/solapi/requests/request_confiog.py b/src/solapi/requests/request_confiog.py new file mode 100644 index 0000000..b5448e6 --- /dev/null +++ b/src/solapi/requests/request_confiog.py @@ -0,0 +1,18 @@ +from datetime import datetime +from typing import NotRequired, TypedDict, Union + + +class SendRequestConfig(TypedDict): + # 예약일시 + scheduledDate: NotRequired[Union[str, datetime]] + + # 중복 수신번호 허용 여부 + # 값 미기입시 중복 수신번호 비허용이 기본값으로 설정됩니다. + allowDuplicates: NotRequired[bool] + + # 특정 solapi app을 통해 발송 시 넣어줘야 할 app ID 값 + appId: NotRequired[str] + + # send 메소드를 통해 발송 시 response에 messageList 값을 표시할 지에 대한 여부 + # 값 미기입시 기본값으로 비표시로 설정됩니다. + showMessageList: NotRequired[bool] diff --git a/src/examples/modules/group/__init__.py b/src/solapi/solapi.py similarity index 100% rename from src/examples/modules/group/__init__.py rename to src/solapi/solapi.py diff --git a/src/examples/modules/kakaotalk/__init__.py b/tests/__init__.py similarity index 100% rename from src/examples/modules/kakaotalk/__init__.py rename to tests/__init__.py From 75d5e7e26427476a6f369e319fe8ed1ec6cacb7d Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 12 Mar 2025 22:15:27 +0900 Subject: [PATCH 02/60] Add LICENSE --- LICENSE | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3806bf9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +MIT License +----------- + +Copyright (c) 2025~ SOLAPI Inc (https://solapi.com) +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file From 8491fd975ae72b06c9c27572eb092c047734657b Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 13 Mar 2025 16:58:47 +0900 Subject: [PATCH 03/60] Change directory structure and Add files --- README.md | 4 +- examples/sms/sms.py | 2 +- poetry.lock | 280 ++++++++++++++++- pyproject.toml | 6 +- {src/solapi => solapi}/__init__.py | 0 {src/solapi => solapi}/lib/__init__.py | 0 solapi/lib/authenticator.py | 42 +++ solapi/lib/fetcher.py | 69 +++++ .../lib/string_date_transfer.py | 5 +- solapi/message_service.py | 66 ++++ .../requests => solapi/model}/__init__.py | 0 solapi/model/message.py | 285 ++++++++++++++++++ .../solapi.py => solapi/requests/__init__.py | 0 .../requests/request_confiog.py | 12 +- solapi/requests/send/__init__.py | 0 solapi/requests/send/send_request.py | 79 +++++ solapi/responses/common_response.py | 47 +++ solapi/responses/send/send_repsonse.py | 40 +++ src/solapi/lib/authenticator.py | 40 --- src/solapi/lib/fetcher.py | 16 - src/solapi/requests/message_request.py | 15 - 21 files changed, 917 insertions(+), 91 deletions(-) rename {src/solapi => solapi}/__init__.py (100%) rename {src/solapi => solapi}/lib/__init__.py (100%) create mode 100644 solapi/lib/authenticator.py create mode 100644 solapi/lib/fetcher.py rename {src/solapi => solapi}/lib/string_date_transfer.py (94%) create mode 100644 solapi/message_service.py rename {src/solapi/requests => solapi/model}/__init__.py (100%) create mode 100644 solapi/model/message.py rename src/solapi/solapi.py => solapi/requests/__init__.py (100%) rename {src/solapi => solapi}/requests/request_confiog.py (63%) create mode 100644 solapi/requests/send/__init__.py create mode 100644 solapi/requests/send/send_request.py create mode 100644 solapi/responses/common_response.py create mode 100644 solapi/responses/send/send_repsonse.py delete mode 100644 src/solapi/lib/authenticator.py delete mode 100644 src/solapi/lib/fetcher.py delete mode 100644 src/solapi/requests/message_request.py diff --git a/README.md b/README.md index 150a2e8..f60de74 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ # Solapi SDK for Python -[![Python 3.10.2](https://img.shields.io/badge/python-3.13.2-blue.svg)](https://www.python.org/downloads) -![Python Supported version](https://img.shields.io/badge/python-%3E%3D3.11-orange) \ No newline at end of file +[![Python 3.13.2](https://img.shields.io/badge/python-3.13.2-blue.svg)](https://www.python.org/downloads) +![Python Supported version](https://img.shields.io/badge/python-%3E%3D3.9-orange) \ No newline at end of file diff --git a/examples/sms/sms.py b/examples/sms/sms.py index 66632ea..1ff8e07 100644 --- a/examples/sms/sms.py +++ b/examples/sms/sms.py @@ -1 +1 @@ -print("test") \ No newline at end of file +print("test") diff --git a/poetry.lock b/poetry.lock index 6858389..d84ce6e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,40 @@ # This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "4.8.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a"}, + {file = "anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] +trio = ["trio (>=0.26.1)"] + [[package]] name = "certifi" version = "2025.1.31" @@ -114,6 +149,81 @@ files = [ {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, ] +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "python_version < \"3.11\"" +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httpcore" +version = "1.0.7" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd"}, + {file = "httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "idna" version = "3.10" @@ -129,6 +239,140 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "pydantic" +version = "2.10.6" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584"}, + {file = "pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.27.2" +typing-extensions = ">=4.12.2" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.27.2" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa"}, + {file = "pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a"}, + {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236"}, + {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962"}, + {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9"}, + {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af"}, + {file = "pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4"}, + {file = "pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31"}, + {file = "pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc"}, + {file = "pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048"}, + {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d"}, + {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b"}, + {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474"}, + {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6"}, + {file = "pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c"}, + {file = "pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc"}, + {file = "pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0"}, + {file = "pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2"}, + {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4"}, + {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9"}, + {file = "pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b"}, + {file = "pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b"}, + {file = "pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e"}, + {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee"}, + {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee"}, + {file = "pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b"}, + {file = "pydantic_core-2.27.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506"}, + {file = "pydantic_core-2.27.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5"}, + {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a"}, + {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d"}, + {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9"}, + {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da"}, + {file = "pydantic_core-2.27.2-cp38-cp38-win32.whl", hash = "sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b"}, + {file = "pydantic_core-2.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad"}, + {file = "pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993"}, + {file = "pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630"}, + {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54"}, + {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f"}, + {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362"}, + {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96"}, + {file = "pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e"}, + {file = "pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9"}, + {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2"}, + {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35"}, + {file = "pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + [[package]] name = "requests" version = "2.32.3" @@ -179,16 +423,40 @@ files = [ {file = "ruff-0.9.10.tar.gz", hash = "sha256:9bacb735d7bada9cfb0f2c227d3658fc443d90a727b47f206fb33f52f3c0eac7"}, ] +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + [[package]] name = "urllib3" -version = "2.2.3" +version = "2.3.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, - {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, + {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, + {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, ] [package.extras] @@ -199,5 +467,5 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.1" -python-versions = ">=3.8" -content-hash = "73b587bcfa37ed87d1848212bfa57b895291d2981d310579c578ccea158cf2a1" +python-versions = ">=3.9" +content-hash = "e2f56e13a0f718d7b6a2d215baae09db5aebac9ba37436552f21debe3c47ded2" diff --git a/pyproject.toml b/pyproject.toml index caa6475..8499b9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,9 +6,11 @@ authors = [ { name = "SOLAPI Team", email = "contact@solapi.com" } ] readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.9" dependencies = [ - "requests (>=2.32.3,<3.0.0)" + "requests (>=2.32.3,<3.0.0)", + "httpx (>=0.28.1,<0.29.0)", + "pydantic (>=2.10.6,<3.0.0)" ] [tool.poetry] diff --git a/src/solapi/__init__.py b/solapi/__init__.py similarity index 100% rename from src/solapi/__init__.py rename to solapi/__init__.py diff --git a/src/solapi/lib/__init__.py b/solapi/lib/__init__.py similarity index 100% rename from src/solapi/lib/__init__.py rename to solapi/lib/__init__.py diff --git a/solapi/lib/authenticator.py b/solapi/lib/authenticator.py new file mode 100644 index 0000000..d4d21ca --- /dev/null +++ b/solapi/lib/authenticator.py @@ -0,0 +1,42 @@ +import datetime +import hashlib +import hmac +import time +import uuid +from typing import TypedDict + + +class AuthenticationParameter(TypedDict): + api_key: str + api_secret: str + + +class Authenticator: + def __init__(self, api_key="", api_secret_key=""): + self.api_key = api_key + self.api_secret_key = api_secret_key + + @staticmethod + def unique_id() -> str: + return uuid.uuid1().hex + + @staticmethod + def get_iso_datetime() -> str: + utc_offset_sec = time.altzone if time.localtime().tm_isdst else time.timezone + utc_offset = datetime.timedelta(seconds=-utc_offset_sec) + return ( + datetime.datetime.now() + .replace(tzinfo=datetime.timezone(offset=utc_offset)) + .isoformat() + ) + + @staticmethod + def get_signature(key: str, msg: str) -> str: + return hmac.new(key.encode(), msg.encode(), hashlib.sha256).hexdigest() + + def get_auth_info(self) -> str: + date = self.get_iso_datetime() + salt = self.unique_id() + data = date + salt + signature = self.get_signature(self.api_secret_key, data) + return f"HMAC-SHA256 ApiKey={self.api_key}, Date={date}, salt={salt}, signature={signature}" diff --git a/solapi/lib/fetcher.py b/solapi/lib/fetcher.py new file mode 100644 index 0000000..a3f0e36 --- /dev/null +++ b/solapi/lib/fetcher.py @@ -0,0 +1,69 @@ +from enum import Enum +from typing import Any, TypedDict, TypeVar + +import httpx +from httpx import Response + +from solapi.lib.authenticator import AuthenticationParameter, Authenticator + + +class RequestMethod(str, Enum): + GET = "GET" + POST = "POST" + PUT = "PUT" + DELETE = "DELETE" + + +class RequestType(TypedDict): + method: RequestMethod + url: str + + +T = TypeVar("T") +R = TypeVar("R") + + +async def default_fetcher( + auth_parameter: AuthenticationParameter, + request: RequestType, + data: T | None = None, +) -> R: + """ + + Args: + auth_parameter: API Key, Api Secret Key Dictionary. 반드시 활성화된 유효한 API Key, API Secret Key를 입력하셔야 합니다. + request: HTTP Request를 위한 파라미터, 정확한 API URL, HTTP Method를 입력해주세요! + data: 발송 혹은 API 요청 시 전달해야 할 파라미터 목록, HTTP GET API를 이용하면 None 타입으로 지정됩니다. + + Returns: + 정상 작동할 경우 JSON 데이터를 반환합니다. + """ + authorization_header_data = Authenticator( + auth_parameter["api_key"], auth_parameter["api_secret"] + ).get_auth_info() + headers = { + "Authorization": authorization_header_data, + "Content-Type": "application/json", + } + + async with httpx.AsyncClient() as client: + response: Response = await client.request( + method=request["method"], url=request["url"], headers=headers, json=data + ) + + # 4xx 에러 처리: 클라이언트 오류일 경우 + # TODO: Custom Error 만들어야 함 + if 400 <= response.status_code < 500: + error_response: dict[str, Any] = response.json() + raise Exception( + error_response.get("errorCode", "UnknownError"), + error_response.get("errorMessage", ""), + ) + # 5xx 에러 처리: 서버 오류일 경우 + elif response.status_code >= 500: + raise Exception("UnknownError", response.text) + + try: + return response.json() + except Exception as exc: + raise Exception(response.text) from exc diff --git a/src/solapi/lib/string_date_transfer.py b/solapi/lib/string_date_transfer.py similarity index 94% rename from src/solapi/lib/string_date_transfer.py rename to solapi/lib/string_date_transfer.py index 45a15a4..8e66768 100644 --- a/src/solapi/lib/string_date_transfer.py +++ b/solapi/lib/string_date_transfer.py @@ -1,6 +1,5 @@ import re from datetime import datetime -from typing import Union class InvalidDateError(Exception): @@ -61,7 +60,7 @@ def parse_iso(date_string: str) -> datetime: raise InvalidDateError("Invalid Date") from e -def string_date_transfer(value: Union[str, datetime]) -> datetime: +def string_date_transfer(value: str | datetime) -> datetime: """ 일반 문자열 날짜가 있을 경우 datetime 타입으로 변환해주는 함수 @@ -83,7 +82,7 @@ def string_date_transfer(value: Union[str, datetime]) -> datetime: return value -def transfer(value: Union[str, datetime]) -> str: +def format_with_transfer(value: str | datetime) -> str: """ string_date_transfer와 format_iso를 한번에 실행하는 함수 diff --git a/solapi/message_service.py b/solapi/message_service.py new file mode 100644 index 0000000..661f525 --- /dev/null +++ b/solapi/message_service.py @@ -0,0 +1,66 @@ +from typing import Optional + +from solapi.lib.authenticator import AuthenticationParameter +from solapi.lib.fetcher import RequestMethod, default_fetcher +from solapi.lib.string_date_transfer import format_with_transfer +from solapi.requests.request_confiog import SendRequestConfig +from solapi.requests.send.send_request import ( + MessageParameter, + MultipleDetailMessageSendingRequest, +) +from solapi.responses.send.send_repsonse import DetailGroupMessageResponse + + +class SolapiMessageService: + def __init__(self, api_key: str, api_secret: str): + self.baseUrl = "https://api.solapi.com" + self._auth_info: AuthenticationParameter = { + "api_key": api_key, + "api_secret": api_secret, + } + + # TODO: Request도 pydantic 모델로 진행 + async def send( + self, + messages: list[MessageParameter] | MessageParameter, + request_config: Optional[SendRequestConfig] = None, + ): + payload = [] + if isinstance(messages, list): + for value in messages: + payload.append(value) + else: + payload.append(messages) + pass + + if len(payload) == 0: + raise Exception("데이터가 반드시 1건 이상 기입되어 있어야 합니다.") + + parameter: MultipleDetailMessageSendingRequest = {"messages": payload} + if request_config["appId"] is not None: + parameter.appId = request_config["appId"] + if request_config["showMessageList"] is not None: + parameter.showMessageList = request_config["showMessageList"] + if request_config["scheduledDate"] is not None: + parameter.scheduledDate = format_with_transfer( + request_config["scheduledDate"] + ) + + response = await default_fetcher( + self._auth_info, + { + "method": RequestMethod("POST"), + "url": f"${self.baseUrl}/messages/v4/send-many/detail", + }, + parameter, + ) + validated_response = DetailGroupMessageResponse.model_validate(response) + count = validated_response.group_info.count + if ( + len(validated_response.failed_message_list) > 0 + and count.total == count.registeredFailed + ): + raise Exception("") + + return validated_response + diff --git a/src/solapi/requests/__init__.py b/solapi/model/__init__.py similarity index 100% rename from src/solapi/requests/__init__.py rename to solapi/model/__init__.py diff --git a/solapi/model/message.py b/solapi/model/message.py new file mode 100644 index 0000000..cd96f9e --- /dev/null +++ b/solapi/model/message.py @@ -0,0 +1,285 @@ +from enum import Enum +from typing import Any, Optional, TypedDict, Union + +from solapi.requests.send.send_request import MessageParameter + + +# 외부 파일 import 대신 타입 정의 +class KakaoOption: + def __init__(self, options: dict[str, Any] = None): + self.options = options or {} + + +class RcsOption: + def __init__(self, options: dict[str, Any] = None): + self.options = options or {} + + +class FileIds(TypedDict): + fileIds: list[str] + + +class MessageType(str, Enum): + """ + 메시지 유형(단문 문자, 장문 문자, 알림톡 등) + SMS: 단문 문자 + LMS: 장문 문자 + MMS: 사진 문자 + ATA: 알림톡 + CTA: 친구톡 + CTI: 사진 한장이 포함된 친구톡 + NSA: 네이버 스마트알림(톡톡) + RCS_SMS: RCS 단문 문자 + RCS_LMS: RCS 장문 문자 + RCS_MMS: RCS 사진 문자 + RCS_TPL: RCS 템플릿 + RCS_ITPL: RCS 이미지 템플릿 + RCS_LTPL: RCS LMS 템플릿 문자 + FAX: 팩스 + VOICE: 음성문자(TTS) + """ + + SMS = "SMS" + LMS = "LMS" + MMS = "MMS" + ATA = "ATA" + CTA = "CTA" + CTI = "CTI" + NSA = "NSA" + RCS_SMS = "RCS_SMS" + RCS_LMS = "RCS_LMS" + RCS_MMS = "RCS_MMS" + RCS_TPL = "RCS_TPL" + RCS_ITPL = "RCS_ITPL" + RCS_LTPL = "RCS_LTPL" + FAX = "FAX" + VOICE = "VOICE" + + +# TODO: 생성자 만들 때 Request 딕셔너리가 아니라 Response 딕셔너리로 받아와야함. +class Message: + def __init__(self, parameter: MessageParameter): + """ + Message 클래스 초기화 + + Args: + parameter: 메시지 파라미터 딕셔너리(json) + """ + # 수신번호 + self._to: Union[str, list[str]] = parameter["to"] + + # 발신번호 + self._from_: Optional[str] = getattr(parameter, "from", None) + + # 메시지 내용 + self._text: Optional[str] = getattr(parameter, "text", None) + + # 메시지 생성일자 + self._date_created: Optional[str] = getattr(parameter, "dateCreated", None) + + # 메시지 수정일자 + self._date_updated: Optional[str] = getattr(parameter, "dateUpdated", None) + + # 메시지의 그룹 ID + self._group_id: Optional[str] = getattr(parameter, "groupId", None) + + # 해당 메시지의 ID + self._message_id: Optional[str] = getattr(parameter, "messageId", None) + + # MMS 전용 스토리지(이미지) ID + self._image_id: Optional[str] = getattr(parameter, "imageId", None) + + # 메시지 유형 + self._type: Optional[MessageType] = getattr(parameter, "type", None) + + # 문자 제목(LMS, MMS 전용) + self._subject: Optional[str] = getattr(parameter, "subject", None) + + # 메시지 타입 감지 여부(비활성화 시 반드시 타입이 명시 되어야 함) + self._auto_type_detect: Optional[bool] = getattr( + parameter, "autoTypeDetect", None + ) + + # 카카오 알림톡/친구톡을 위한 프로퍼티 + self._kakao_options: Optional[KakaoOption] = getattr( + parameter, "kakaoOptions", None + ) + + # RCS 메시지를 위한 프로퍼티 + self._rcs_options: Optional[RcsOption] = getattr(parameter, "rcsOptions", None) + + # 해외 문자 발송을 위한 국가번호(예) "82", "1" 등) + self._country: Optional[str] = getattr(parameter, "country", None) + + # 메시지 로그 + self._log: Optional[list[dict[str, Any]]] = getattr(parameter, "log", None) + + # 대체 텍스트 + self._replacements: Optional[list[dict[str, Any]]] = getattr( + parameter, "replacements", None + ) + + # 메시지 상태 코드 + # https://developers.solapi.com/references/message-status-codes + self._status_code: Optional[str] = getattr(parameter, "status", None) + + # 사용자를 위한 사용자만의 커스텀 값을 입력할 수 있는 필드 + # 단, 오브젝트 내 키 값 모두 문자열 형태로 입력되어야 합니다. + self._custom_fields: Optional[dict[str, str]] = getattr( + parameter, "customFields", None + ) + + # 팩스 옵션 + self._fax_options: Optional[FileIds] = getattr(parameter, "faxOptions", None) + + @property + def to(self) -> Union[str, list[str]]: + return self._to + + @to.setter + def to(self, value: Union[str, list[str]]) -> None: + self._to = value + + @property + def from_(self) -> Optional[str]: + return self._from_ + + @from_.setter + def from_(self, value: Optional[str]) -> None: + self._from_ = value + + @property + def text(self) -> Optional[str]: + return self._text + + @text.setter + def text(self, value: Optional[str]) -> None: + self._text = value + + @property + def date_created(self) -> Optional[str]: + return self._date_created + + @date_created.setter + def date_created(self, value: Optional[str]) -> None: + self._date_created = value + + @property + def date_updated(self) -> Optional[str]: + return self._date_updated + + @date_updated.setter + def date_updated(self, value: Optional[str]) -> None: + self._date_updated = value + + @property + def group_id(self) -> Optional[str]: + return self._group_id + + @group_id.setter + def group_id(self, value: Optional[str]) -> None: + self._group_id = value + + @property + def message_id(self) -> Optional[str]: + return self._message_id + + @message_id.setter + def message_id(self, value: Optional[str]) -> None: + self._message_id = value + + @property + def image_id(self) -> Optional[str]: + return self._image_id + + @image_id.setter + def image_id(self, value: Optional[str]) -> None: + self._image_id = value + + @property + def type(self) -> Optional[MessageType]: + return self._type + + @type.setter + def type(self, value: Optional[MessageType]) -> None: + self._type = value + + @property + def subject(self) -> Optional[str]: + return self._subject + + @subject.setter + def subject(self, value: Optional[str]) -> None: + self._subject = value + + @property + def auto_type_detect(self) -> Optional[bool]: + return self._auto_type_detect + + @auto_type_detect.setter + def auto_type_detect(self, value: Optional[bool]) -> None: + self._auto_type_detect = value + + @property + def kakao_options(self) -> Optional[KakaoOption]: + return self._kakao_options + + @kakao_options.setter + def kakao_options(self, value: Optional[KakaoOption]) -> None: + self._kakao_options = value + + @property + def rcs_options(self) -> Optional[RcsOption]: + return self._rcs_options + + @rcs_options.setter + def rcs_options(self, value: Optional[RcsOption]) -> None: + self._rcs_options = value + + @property + def country(self) -> Optional[str]: + return self._country + + @country.setter + def country(self, value: Optional[str]) -> None: + self._country = value + + @property + def log(self) -> Optional[list[dict[str, Any]]]: + return self._log + + @log.setter + def log(self, value: Optional[list[dict[str, Any]]]) -> None: + self._log = value + + @property + def replacements(self) -> Optional[list[dict[str, Any]]]: + return self._replacements + + @replacements.setter + def replacements(self, value: Optional[list[dict[str, Any]]]) -> None: + self._replacements = value + + @property + def status_code(self) -> Optional[str]: + return self._status_code + + @status_code.setter + def status_code(self, value: Optional[str]) -> None: + self._status_code = value + + @property + def custom_fields(self) -> Optional[dict[str, str]]: + return self._custom_fields + + @custom_fields.setter + def custom_fields(self, value: Optional[dict[str, str]]) -> None: + self._custom_fields = value + + @property + def fax_options(self) -> Optional[FileIds]: + return self._fax_options + + @fax_options.setter + def fax_options(self, value: Optional[FileIds]) -> None: + self._fax_options = value diff --git a/src/solapi/solapi.py b/solapi/requests/__init__.py similarity index 100% rename from src/solapi/solapi.py rename to solapi/requests/__init__.py diff --git a/src/solapi/requests/request_confiog.py b/solapi/requests/request_confiog.py similarity index 63% rename from src/solapi/requests/request_confiog.py rename to solapi/requests/request_confiog.py index b5448e6..32c8f4c 100644 --- a/src/solapi/requests/request_confiog.py +++ b/solapi/requests/request_confiog.py @@ -1,18 +1,18 @@ from datetime import datetime -from typing import NotRequired, TypedDict, Union +from typing import TypedDict -class SendRequestConfig(TypedDict): +class SendRequestConfig(TypedDict, total=False): # 예약일시 - scheduledDate: NotRequired[Union[str, datetime]] + scheduledDate: str | datetime # 중복 수신번호 허용 여부 # 값 미기입시 중복 수신번호 비허용이 기본값으로 설정됩니다. - allowDuplicates: NotRequired[bool] + allowDuplicates: bool # 특정 solapi app을 통해 발송 시 넣어줘야 할 app ID 값 - appId: NotRequired[str] + appId: str # send 메소드를 통해 발송 시 response에 messageList 값을 표시할 지에 대한 여부 # 값 미기입시 기본값으로 비표시로 설정됩니다. - showMessageList: NotRequired[bool] + showMessageList: bool diff --git a/solapi/requests/send/__init__.py b/solapi/requests/send/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solapi/requests/send/send_request.py b/solapi/requests/send/send_request.py new file mode 100644 index 0000000..052efe4 --- /dev/null +++ b/solapi/requests/send/send_request.py @@ -0,0 +1,79 @@ +import platform +from typing import Any, TypedDict, Union + +from solapi.model.message import MessageType + + +class MessageParameterRequired(TypedDict): + to: Union[str, list[str]] + + +MessageParameterNonRequired = TypedDict( + "MessageParameterNonRequired", + { + "from": str, + "text": str, + "imageId": str, + "type": MessageType, + "subject": str, + "autoTypeDetect": bool, + "kakaoOptions": dict[str, Any], + "rcsOptions": dict[str, Any], + "country": str, + "customFields": dict[str, str], + "replacements": list[object], + "faxOptions": dict[str, Any], + }, + total=False, +) + + +class SdkInfo(TypedDict): + sdkVersion: str + osPlatform: str + + +class AppId(TypedDict, total=False): + appId: str + + +class DefaultAgentType(SdkInfo, AppId): + pass + + +# 필요한 타입 정의 (실제 환경에 맞게 구체화 가능) +RcsOptionRequest = dict[str, Any] +FileIds = dict[str, Any] + + +class KakaoOptionRequest(TypedDict): + pass + + +class MessageParameter(MessageParameterRequired, MessageParameterNonRequired): + pass + + +class DefaultMessageRequest(TypedDict): + allowDuplicates: bool + agent: DefaultAgentType + + +default_agent: DefaultAgentType = { + "sdkVersion": "python/5.0.0", + "osPlatform": f"{platform.platform()} | {platform.python_version()}", +} + + +class ScheduledDateType(TypedDict, total=False): + scheduledDate: str + + +class ShowMessageListType(TypedDict, total=False): + showMessageList: bool + + +class MultipleDetailMessageSendingRequest( + TypedDict, DefaultMessageRequest, AppId, ShowMessageListType +): + messages: list[MessageParameter] diff --git a/solapi/responses/common_response.py b/solapi/responses/common_response.py new file mode 100644 index 0000000..7770a07 --- /dev/null +++ b/solapi/responses/common_response.py @@ -0,0 +1,47 @@ +from typing import Any, Optional + +from pydantic import BaseModel, Field + +from solapi.message_service import SolapiMessageService + + +class GroupMessageResponse(BaseModel): + count: Any = Field(..., alias="count") + count_for_charge: Any = Field(..., alias="countForCharge") + balance: Any = Field(..., alias="balance") + point: Any = Field(..., alias="point") + app: Any = Field(..., alias="app") + log: Any = Field(..., alias="log") + status: str = Field(..., alias="status") + allow_duplicates: bool = Field(..., alias="allowDuplicates") + is_refunded: bool = Field(..., alias="isRefunded") + account_id: str = Field(..., alias="accountId") + master_account_id: Optional[str] = Field(None, alias="masterAccountId") + api_version: str = Field(..., alias="apiVersion") + group_id: str = Field(..., alias="groupId") + price: dict[str, Any] = Field(..., alias="price") + date_created: str = Field(..., alias="dateCreated") + date_updated: str = Field(..., alias="dateUpdated") + scheduled_date: Optional[str] = Field(None, alias="scheduledDate") + date_sent: Optional[str] = Field(None, alias="dateSent") + date_completed: Optional[str] = Field(None, alias="dateCompleted") + + class Config: + allow_population_by_field_name = True + +class Count(BaseModel): + total: int = Field(..., alias="total") + sent_total: int = Field(..., alias="sentTotal") + sent_failed: int = Field(..., alias="sentFailed") + sent_success: int = Field(..., alias="sentSuccess") + sent_pending: int = Field(..., alias="sentPending") + sent_replacement: int = Field(..., alias="sentReplacement") + refund: int = Field(..., alias="refund") + registered_failed: int = Field(..., alias="registeredFailed") + registered_success: int = Field(..., alias="registeredSuccess") + + class Config: + allow_population_by_field_name = True + + +message_service = SolapiMessageService() \ No newline at end of file diff --git a/solapi/responses/send/send_repsonse.py b/solapi/responses/send/send_repsonse.py new file mode 100644 index 0000000..86d385e --- /dev/null +++ b/solapi/responses/send/send_repsonse.py @@ -0,0 +1,40 @@ +from typing import Optional + +from pydantic import BaseModel, Field + +from solapi.responses.common_response import GroupMessageResponse + + +class FailedMessage(BaseModel): + to: str = Field(..., alias="to") + # 'from'는 예약어이므로 내부 변수는 from_로 사용하고, alias로 "from"을 매핑합니다. + from_: str = Field(..., alias="from") + type: str = Field(..., alias="type") + status_message: str = Field(..., alias="statusMessage") + country: str = Field(..., alias="country") + message_id: str = Field(..., alias="messageId") + status_code: str = Field(..., alias="statusCode") + account_id: str = Field(..., alias="accountId") + custom_fields: Optional[dict[str, str]] = Field(None, alias="customFields") + + class Config: + allow_population_by_field_name = True + + +class MessageResponseItem(BaseModel): + message_id: str = Field(..., alias="messageId") + status_code: str = Field(..., alias="statusCode") + custom_fields: Optional[dict[str, str]] = Field(None, alias="customFields") + status_message: str = Field(..., alias="statusMessage") + + class Config: + allow_population_by_field_name = True + + +class DetailGroupMessageResponse(BaseModel): + failed_message_list: list[FailedMessage] = Field(..., alias="failedMessageList") + group_info: GroupMessageResponse = Field(..., alias="groupInfo") + message_list: Optional[list[MessageResponseItem]] = Field(None, alias="messageList") + + class Config: + allow_population_by_field_name = True diff --git a/src/solapi/lib/authenticator.py b/src/solapi/lib/authenticator.py deleted file mode 100644 index 0804822..0000000 --- a/src/solapi/lib/authenticator.py +++ /dev/null @@ -1,40 +0,0 @@ -import datetime -import hashlib -import hmac -import time -import uuid - - -def unique_id(): - return str(uuid.uuid1().hex) - - -def get_iso_datetime(): - utc_offset_sec = time.altzone if time.localtime().tm_isdst else time.timezone - utc_offset = datetime.timedelta(seconds=-utc_offset_sec) - return ( - datetime.datetime.now() - .replace(tzinfo=datetime.timezone(offset=utc_offset)) - .isoformat() - ) - - -def get_signature(key="", msg=""): - return hmac.new(key.encode(), msg.encode(), hashlib.sha256).hexdigest() - - -def get_headers(api_key="", api_secret_key=""): - date = get_iso_datetime() - salt = unique_id() - data = date + salt - return { - "Authorization": "HMAC-SHA256 ApiKey=" - + api_key - + ", Date=" - + date - + ", salt=" - + salt - + ", signature=" - + get_signature(api_secret_key, data), - "Content-Type": "application/json; charset=utf-8", - } \ No newline at end of file diff --git a/src/solapi/lib/fetcher.py b/src/solapi/lib/fetcher.py deleted file mode 100644 index f56f261..0000000 --- a/src/solapi/lib/fetcher.py +++ /dev/null @@ -1,16 +0,0 @@ -from typing import TypeVar - -# from authenticator import get_headers - - -def get_url(path): - url: str = "https://api.solapi.com" - return url + path - - -T = TypeVar("T") -R = TypeVar("R") - - -def fetch(request, data: T) -> R: - return "" diff --git a/src/solapi/requests/message_request.py b/src/solapi/requests/message_request.py deleted file mode 100644 index ff93ad8..0000000 --- a/src/solapi/requests/message_request.py +++ /dev/null @@ -1,15 +0,0 @@ -import platform -import sys -from typing import NotRequired, TypedDict - - -class DefaultAgentType(TypedDict): - sdkVersion: str - osPlatform: str - appId: NotRequired[str] - - -default_agent: DefaultAgentType = { - "sdkVersion": "python/5.0.0", - "osPlatform": f"{platform.system().lower()} | {sys.version.split()[0]}", -} \ No newline at end of file From 04dc3b9cfc0ffe9c0694ec6b8a71d7f8f54b9d58 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 14 Mar 2025 22:11:57 +0900 Subject: [PATCH 04/60] [Chore] Update files --- pyproject.toml | 19 +++++ solapi/message_service.py | 4 +- solapi/model/message.py | 104 +++++++------------------ solapi/requests/send/send_request.py | 4 +- solapi/responses/common_response.py | 9 +-- solapi/responses/send/send_repsonse.py | 6 +- 6 files changed, 56 insertions(+), 90 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8499b9c..f9325a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,25 @@ description = "SOLAPI SDK for Python" authors = [ { name = "SOLAPI Team", email = "contact@solapi.com" } ] +classifiers = [ + 'Development Status :: 5 - Production/Stable', + 'Programming Language :: Python', + 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Implementation :: PyPy', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3 :: Only', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', + 'Intended Audience :: Developers', + 'Intended Audience :: Information Technology', + 'License :: OSI Approved :: MIT License', + 'Operating System :: OS Independent', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Topic :: Internet', +] readme = "README.md" requires-python = ">=3.9" dependencies = [ diff --git a/solapi/message_service.py b/solapi/message_service.py index 661f525..9069427 100644 --- a/solapi/message_service.py +++ b/solapi/message_service.py @@ -3,9 +3,9 @@ from solapi.lib.authenticator import AuthenticationParameter from solapi.lib.fetcher import RequestMethod, default_fetcher from solapi.lib.string_date_transfer import format_with_transfer +from solapi.model.message import Message from solapi.requests.request_confiog import SendRequestConfig from solapi.requests.send.send_request import ( - MessageParameter, MultipleDetailMessageSendingRequest, ) from solapi.responses.send.send_repsonse import DetailGroupMessageResponse @@ -22,7 +22,7 @@ def __init__(self, api_key: str, api_secret: str): # TODO: Request도 pydantic 모델로 진행 async def send( self, - messages: list[MessageParameter] | MessageParameter, + messages: list[Message] | Message, request_config: Optional[SendRequestConfig] = None, ): payload = [] diff --git a/solapi/model/message.py b/solapi/model/message.py index cd96f9e..771cdae 100644 --- a/solapi/model/message.py +++ b/solapi/model/message.py @@ -1,7 +1,8 @@ +from datetime import datetime from enum import Enum from typing import Any, Optional, TypedDict, Union -from solapi.requests.send.send_request import MessageParameter +from pydantic import BaseModel, Field # 외부 파일 import 대신 타입 정의 @@ -57,80 +58,28 @@ class MessageType(str, Enum): # TODO: 생성자 만들 때 Request 딕셔너리가 아니라 Response 딕셔너리로 받아와야함. -class Message: - def __init__(self, parameter: MessageParameter): - """ - Message 클래스 초기화 +class Message(BaseModel): + _to: str + from__: Optional[str] = Field(None, alias="from") + _text: Optional[str] = None + _date_created: Optional[datetime] = None + _date_updated: Optional[datetime] = None + _group_id: Optional[str] = None + _message_id: Optional[str] = None + _image_id: Optional[str] = None + _log: Optional[str] = None + _type: Optional[str] = None + _subject: Optional[str] = None + # TODO: kakao options, rcs options 타이핑 해야 함 + _kakao_options: Optional[dict[str, Any]] = None + _rcs_options: Optional[dict[str, Any]] = None + _country: Optional[str] = None + _auto_type_detect: Optional[bool] = None + _status_code: Optional[str] = None + _replacements: Optional[dict[str, Any]] = None + _custom_fields: Optional[dict[str, Any]] = None + _fax_options: Optional[dict[str, Any]] = None - Args: - parameter: 메시지 파라미터 딕셔너리(json) - """ - # 수신번호 - self._to: Union[str, list[str]] = parameter["to"] - - # 발신번호 - self._from_: Optional[str] = getattr(parameter, "from", None) - - # 메시지 내용 - self._text: Optional[str] = getattr(parameter, "text", None) - - # 메시지 생성일자 - self._date_created: Optional[str] = getattr(parameter, "dateCreated", None) - - # 메시지 수정일자 - self._date_updated: Optional[str] = getattr(parameter, "dateUpdated", None) - - # 메시지의 그룹 ID - self._group_id: Optional[str] = getattr(parameter, "groupId", None) - - # 해당 메시지의 ID - self._message_id: Optional[str] = getattr(parameter, "messageId", None) - - # MMS 전용 스토리지(이미지) ID - self._image_id: Optional[str] = getattr(parameter, "imageId", None) - - # 메시지 유형 - self._type: Optional[MessageType] = getattr(parameter, "type", None) - - # 문자 제목(LMS, MMS 전용) - self._subject: Optional[str] = getattr(parameter, "subject", None) - - # 메시지 타입 감지 여부(비활성화 시 반드시 타입이 명시 되어야 함) - self._auto_type_detect: Optional[bool] = getattr( - parameter, "autoTypeDetect", None - ) - - # 카카오 알림톡/친구톡을 위한 프로퍼티 - self._kakao_options: Optional[KakaoOption] = getattr( - parameter, "kakaoOptions", None - ) - - # RCS 메시지를 위한 프로퍼티 - self._rcs_options: Optional[RcsOption] = getattr(parameter, "rcsOptions", None) - - # 해외 문자 발송을 위한 국가번호(예) "82", "1" 등) - self._country: Optional[str] = getattr(parameter, "country", None) - - # 메시지 로그 - self._log: Optional[list[dict[str, Any]]] = getattr(parameter, "log", None) - - # 대체 텍스트 - self._replacements: Optional[list[dict[str, Any]]] = getattr( - parameter, "replacements", None - ) - - # 메시지 상태 코드 - # https://developers.solapi.com/references/message-status-codes - self._status_code: Optional[str] = getattr(parameter, "status", None) - - # 사용자를 위한 사용자만의 커스텀 값을 입력할 수 있는 필드 - # 단, 오브젝트 내 키 값 모두 문자열 형태로 입력되어야 합니다. - self._custom_fields: Optional[dict[str, str]] = getattr( - parameter, "customFields", None - ) - - # 팩스 옵션 - self._fax_options: Optional[FileIds] = getattr(parameter, "faxOptions", None) @property def to(self) -> Union[str, list[str]]: @@ -142,11 +91,11 @@ def to(self, value: Union[str, list[str]]) -> None: @property def from_(self) -> Optional[str]: - return self._from_ + return self.__dict__.get("from") @from_.setter def from_(self, value: Optional[str]) -> None: - self._from_ = value + self.from__ = value @property def text(self) -> Optional[str]: @@ -283,3 +232,6 @@ def fax_options(self) -> Optional[FileIds]: @fax_options.setter def fax_options(self, value: Optional[FileIds]) -> None: self._fax_options = value + + class Config: + populate_by_name = True diff --git a/solapi/requests/send/send_request.py b/solapi/requests/send/send_request.py index 052efe4..1c206f1 100644 --- a/solapi/requests/send/send_request.py +++ b/solapi/requests/send/send_request.py @@ -1,7 +1,7 @@ import platform from typing import Any, TypedDict, Union -from solapi.model.message import MessageType +from solapi.model.message import Message, MessageType class MessageParameterRequired(TypedDict): @@ -76,4 +76,4 @@ class ShowMessageListType(TypedDict, total=False): class MultipleDetailMessageSendingRequest( TypedDict, DefaultMessageRequest, AppId, ShowMessageListType ): - messages: list[MessageParameter] + messages: list[Message] diff --git a/solapi/responses/common_response.py b/solapi/responses/common_response.py index 7770a07..aebf9c6 100644 --- a/solapi/responses/common_response.py +++ b/solapi/responses/common_response.py @@ -2,8 +2,6 @@ from pydantic import BaseModel, Field -from solapi.message_service import SolapiMessageService - class GroupMessageResponse(BaseModel): count: Any = Field(..., alias="count") @@ -27,7 +25,7 @@ class GroupMessageResponse(BaseModel): date_completed: Optional[str] = Field(None, alias="dateCompleted") class Config: - allow_population_by_field_name = True + populate_by_name = True class Count(BaseModel): total: int = Field(..., alias="total") @@ -41,7 +39,4 @@ class Count(BaseModel): registered_success: int = Field(..., alias="registeredSuccess") class Config: - allow_population_by_field_name = True - - -message_service = SolapiMessageService() \ No newline at end of file + populate_by_name = True diff --git a/solapi/responses/send/send_repsonse.py b/solapi/responses/send/send_repsonse.py index 86d385e..f1b1436 100644 --- a/solapi/responses/send/send_repsonse.py +++ b/solapi/responses/send/send_repsonse.py @@ -18,7 +18,7 @@ class FailedMessage(BaseModel): custom_fields: Optional[dict[str, str]] = Field(None, alias="customFields") class Config: - allow_population_by_field_name = True + populate_by_name = True class MessageResponseItem(BaseModel): @@ -28,7 +28,7 @@ class MessageResponseItem(BaseModel): status_message: str = Field(..., alias="statusMessage") class Config: - allow_population_by_field_name = True + populate_by_name = True class DetailGroupMessageResponse(BaseModel): @@ -37,4 +37,4 @@ class DetailGroupMessageResponse(BaseModel): message_list: Optional[list[MessageResponseItem]] = Field(None, alias="messageList") class Config: - allow_population_by_field_name = True + populate_by_name = True From 66590ae41cfb51648e981c50151f7e7f36416a7e Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Sun, 16 Mar 2025 21:05:40 +0900 Subject: [PATCH 05/60] Migrate poetry to uv: Update BaseModels --- examples/sms/sms.py | 12 +- poetry.lock | 471 ------------------ pyproject.toml | 15 +- .ruff.toml => ruff.toml | 29 ++ solapi/lib/fetcher.py | 21 +- solapi/lib/string_date_transfer.py | 4 +- solapi/message_service.py | 66 +-- solapi/model/fax.py | 5 + solapi/model/message.py | 215 +------- .../{requests => model/request}/__init__.py | 0 solapi/model/request/send_message_request.py | 28 ++ solapi/requests/request_confiog.py | 18 - solapi/requests/send/__init__.py | 0 solapi/requests/send/send_request.py | 79 --- solapi/responses/common_response.py | 42 -- solapi/responses/send/send_repsonse.py | 40 -- uv.lock | 267 ++++++++++ 17 files changed, 403 insertions(+), 909 deletions(-) delete mode 100644 poetry.lock rename .ruff.toml => ruff.toml (66%) create mode 100644 solapi/model/fax.py rename solapi/{requests => model/request}/__init__.py (100%) create mode 100644 solapi/model/request/send_message_request.py delete mode 100644 solapi/requests/request_confiog.py delete mode 100644 solapi/requests/send/__init__.py delete mode 100644 solapi/requests/send/send_request.py delete mode 100644 solapi/responses/common_response.py delete mode 100644 solapi/responses/send/send_repsonse.py create mode 100644 uv.lock diff --git a/examples/sms/sms.py b/examples/sms/sms.py index 1ff8e07..759d9dd 100644 --- a/examples/sms/sms.py +++ b/examples/sms/sms.py @@ -1 +1,11 @@ -print("test") + +from solapi.message_service import SolapiMessageService +from solapi.model.message import Message + +test = SolapiMessageService(api_key="", api_secret="") +test2 = Message( + from_="029302266", + to="01012345678", + text="test" +) +test.send(test2) diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index d84ce6e..0000000 --- a/poetry.lock +++ /dev/null @@ -1,471 +0,0 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. - -[[package]] -name = "annotated-types" -version = "0.7.0" -description = "Reusable constraint types to use with typing.Annotated" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, -] - -[[package]] -name = "anyio" -version = "4.8.0" -description = "High level compatibility layer for multiple asynchronous event loop implementations" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a"}, - {file = "anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a"}, -] - -[package.dependencies] -exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} -idna = ">=2.8" -sniffio = ">=1.1" -typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} - -[package.extras] -doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] -trio = ["trio (>=0.26.1)"] - -[[package]] -name = "certifi" -version = "2025.1.31" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, - {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.1" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, - {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, - {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, -] - -[[package]] -name = "exceptiongroup" -version = "1.2.2" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -groups = ["main"] -markers = "python_version < \"3.11\"" -files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, -] - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "h11" -version = "0.14.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, -] - -[[package]] -name = "httpcore" -version = "1.0.7" -description = "A minimal low-level HTTP client." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd"}, - {file = "httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c"}, -] - -[package.dependencies] -certifi = "*" -h11 = ">=0.13,<0.15" - -[package.extras] -asyncio = ["anyio (>=4.0,<5.0)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -trio = ["trio (>=0.22.0,<1.0)"] - -[[package]] -name = "httpx" -version = "0.28.1" -description = "The next generation HTTP client." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, - {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, -] - -[package.dependencies] -anyio = "*" -certifi = "*" -httpcore = "==1.*" -idna = "*" - -[package.extras] -brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] -cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "idna" -version = "3.10" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, -] - -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "pydantic" -version = "2.10.6" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584"}, - {file = "pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236"}, -] - -[package.dependencies] -annotated-types = ">=0.6.0" -pydantic-core = "2.27.2" -typing-extensions = ">=4.12.2" - -[package.extras] -email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] - -[[package]] -name = "pydantic-core" -version = "2.27.2" -description = "Core functionality for Pydantic validation and serialization" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa"}, - {file = "pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c"}, - {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a"}, - {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5"}, - {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c"}, - {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7"}, - {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a"}, - {file = "pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236"}, - {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962"}, - {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9"}, - {file = "pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af"}, - {file = "pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4"}, - {file = "pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31"}, - {file = "pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc"}, - {file = "pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048"}, - {file = "pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d"}, - {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b"}, - {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474"}, - {file = "pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6"}, - {file = "pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c"}, - {file = "pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc"}, - {file = "pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4"}, - {file = "pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0"}, - {file = "pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef"}, - {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7"}, - {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934"}, - {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6"}, - {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c"}, - {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2"}, - {file = "pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4"}, - {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3"}, - {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4"}, - {file = "pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57"}, - {file = "pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc"}, - {file = "pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9"}, - {file = "pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b"}, - {file = "pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b"}, - {file = "pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154"}, - {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9"}, - {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9"}, - {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1"}, - {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a"}, - {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e"}, - {file = "pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4"}, - {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27"}, - {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee"}, - {file = "pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1"}, - {file = "pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130"}, - {file = "pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee"}, - {file = "pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b"}, - {file = "pydantic_core-2.27.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506"}, - {file = "pydantic_core-2.27.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320"}, - {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145"}, - {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1"}, - {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228"}, - {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046"}, - {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5"}, - {file = "pydantic_core-2.27.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a"}, - {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d"}, - {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9"}, - {file = "pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da"}, - {file = "pydantic_core-2.27.2-cp38-cp38-win32.whl", hash = "sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b"}, - {file = "pydantic_core-2.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad"}, - {file = "pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993"}, - {file = "pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308"}, - {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4"}, - {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf"}, - {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76"}, - {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118"}, - {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630"}, - {file = "pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54"}, - {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f"}, - {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362"}, - {file = "pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96"}, - {file = "pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e"}, - {file = "pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9"}, - {file = "pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2"}, - {file = "pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35"}, - {file = "pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39"}, -] - -[package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" - -[[package]] -name = "requests" -version = "2.32.3" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "ruff" -version = "0.9.10" -description = "An extremely fast Python linter and code formatter, written in Rust." -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "ruff-0.9.10-py3-none-linux_armv6l.whl", hash = "sha256:eb4d25532cfd9fe461acc83498361ec2e2252795b4f40b17e80692814329e42d"}, - {file = "ruff-0.9.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:188a6638dab1aa9bb6228a7302387b2c9954e455fb25d6b4470cb0641d16759d"}, - {file = "ruff-0.9.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5284dcac6b9dbc2fcb71fdfc26a217b2ca4ede6ccd57476f52a587451ebe450d"}, - {file = "ruff-0.9.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47678f39fa2a3da62724851107f438c8229a3470f533894b5568a39b40029c0c"}, - {file = "ruff-0.9.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:99713a6e2766b7a17147b309e8c915b32b07a25c9efd12ada79f217c9c778b3e"}, - {file = "ruff-0.9.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:524ee184d92f7c7304aa568e2db20f50c32d1d0caa235d8ddf10497566ea1a12"}, - {file = "ruff-0.9.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:df92aeac30af821f9acf819fc01b4afc3dfb829d2782884f8739fb52a8119a16"}, - {file = "ruff-0.9.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de42e4edc296f520bb84954eb992a07a0ec5a02fecb834498415908469854a52"}, - {file = "ruff-0.9.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d257f95b65806104b6b1ffca0ea53f4ef98454036df65b1eda3693534813ecd1"}, - {file = "ruff-0.9.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b60dec7201c0b10d6d11be00e8f2dbb6f40ef1828ee75ed739923799513db24c"}, - {file = "ruff-0.9.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d838b60007da7a39c046fcdd317293d10b845001f38bcb55ba766c3875b01e43"}, - {file = "ruff-0.9.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ccaf903108b899beb8e09a63ffae5869057ab649c1e9231c05ae354ebc62066c"}, - {file = "ruff-0.9.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f9567d135265d46e59d62dc60c0bfad10e9a6822e231f5b24032dba5a55be6b5"}, - {file = "ruff-0.9.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5f202f0d93738c28a89f8ed9eaba01b7be339e5d8d642c994347eaa81c6d75b8"}, - {file = "ruff-0.9.10-py3-none-win32.whl", hash = "sha256:bfb834e87c916521ce46b1788fbb8484966e5113c02df216680102e9eb960029"}, - {file = "ruff-0.9.10-py3-none-win_amd64.whl", hash = "sha256:f2160eeef3031bf4b17df74e307d4c5fb689a6f3a26a2de3f7ef4044e3c484f1"}, - {file = "ruff-0.9.10-py3-none-win_arm64.whl", hash = "sha256:5fd804c0327a5e5ea26615550e706942f348b197d5475ff34c19733aee4b2e69"}, - {file = "ruff-0.9.10.tar.gz", hash = "sha256:9bacb735d7bada9cfb0f2c227d3658fc443d90a727b47f206fb33f52f3c0eac7"}, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -description = "Sniff out which async library your code is running under" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, - {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, -] - -[[package]] -name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, -] - -[[package]] -name = "urllib3" -version = "2.3.0" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, - {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, -] - -[package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] - -[metadata] -lock-version = "2.1" -python-versions = ">=3.9" -content-hash = "e2f56e13a0f718d7b6a2d215baae09db5aebac9ba37436552f21debe3c47ded2" diff --git a/pyproject.toml b/pyproject.toml index f9325a0..b6e124d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,6 @@ authors = [ { name = "SOLAPI Team", email = "contact@solapi.com" } ] classifiers = [ - 'Development Status :: 5 - Production/Stable', 'Programming Language :: Python', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', @@ -27,17 +26,15 @@ classifiers = [ readme = "README.md" requires-python = ">=3.9" dependencies = [ - "requests (>=2.32.3,<3.0.0)", "httpx (>=0.28.1,<0.29.0)", "pydantic (>=2.10.6,<3.0.0)" ] -[tool.poetry] -packages = [{ include = "solapi", from = "src" }] - -[tool.poetry.group.dev.dependencies] -ruff = "^0.9.10" +[project.optional-dependencies] +dev = [ + "ruff>=0.11.0" +] [build-system] -requires = ["poetry-core>=2.0.0,<3.0.0"] -build-backend = "poetry.core.masonry.api" +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/.ruff.toml b/ruff.toml similarity index 66% rename from .ruff.toml rename to ruff.toml index 1ebcbfb..ce81e9c 100644 --- a/.ruff.toml +++ b/ruff.toml @@ -20,6 +20,35 @@ ignore = [] # 언더스코어로 시작하는 변수는 미사용 변수로 간주하지 않음 dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", +] + [format] # 문자열에 큰따옴표 사용 quote-style = "double" diff --git a/solapi/lib/fetcher.py b/solapi/lib/fetcher.py index a3f0e36..648f103 100644 --- a/solapi/lib/fetcher.py +++ b/solapi/lib/fetcher.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import Any, TypedDict, TypeVar +from typing import Any, Optional, TypedDict import httpx from httpx import Response @@ -14,22 +14,17 @@ class RequestMethod(str, Enum): DELETE = "DELETE" -class RequestType(TypedDict): +class RequestParameterType(TypedDict): method: RequestMethod url: str -T = TypeVar("T") -R = TypeVar("R") - - -async def default_fetcher( +def default_fetcher( auth_parameter: AuthenticationParameter, - request: RequestType, - data: T | None = None, -) -> R: + request: RequestParameterType, + data: Optional[Any] = None, +): """ - Args: auth_parameter: API Key, Api Secret Key Dictionary. 반드시 활성화된 유효한 API Key, API Secret Key를 입력하셔야 합니다. request: HTTP Request를 위한 파라미터, 정확한 API URL, HTTP Method를 입력해주세요! @@ -46,8 +41,8 @@ async def default_fetcher( "Content-Type": "application/json", } - async with httpx.AsyncClient() as client: - response: Response = await client.request( + with httpx.Client() as client: + response: Response = client.request( method=request["method"], url=request["url"], headers=headers, json=data ) diff --git a/solapi/lib/string_date_transfer.py b/solapi/lib/string_date_transfer.py index 8e66768..db8d301 100644 --- a/solapi/lib/string_date_transfer.py +++ b/solapi/lib/string_date_transfer.py @@ -60,7 +60,7 @@ def parse_iso(date_string: str) -> datetime: raise InvalidDateError("Invalid Date") from e -def string_date_transfer(value: str | datetime) -> datetime: +def string_date_transfer(value: str | datetime): """ 일반 문자열 날짜가 있을 경우 datetime 타입으로 변환해주는 함수 @@ -82,7 +82,7 @@ def string_date_transfer(value: str | datetime) -> datetime: return value -def format_with_transfer(value: str | datetime) -> str: +def format_with_transfer(value: str) -> str: """ string_date_transfer와 format_iso를 한번에 실행하는 함수 diff --git a/solapi/message_service.py b/solapi/message_service.py index 9069427..a0e237a 100644 --- a/solapi/message_service.py +++ b/solapi/message_service.py @@ -1,14 +1,13 @@ -from typing import Optional +from typing import Optional, Union from solapi.lib.authenticator import AuthenticationParameter from solapi.lib.fetcher import RequestMethod, default_fetcher from solapi.lib.string_date_transfer import format_with_transfer from solapi.model.message import Message -from solapi.requests.request_confiog import SendRequestConfig -from solapi.requests.send.send_request import ( - MultipleDetailMessageSendingRequest, +from solapi.model.request.send_message_request import ( + SendMessageRequest, + SendRequestConfig, ) -from solapi.responses.send.send_repsonse import DetailGroupMessageResponse class SolapiMessageService: @@ -19,48 +18,31 @@ def __init__(self, api_key: str, api_secret: str): "api_secret": api_secret, } - # TODO: Request도 pydantic 모델로 진행 - async def send( + def send( self, - messages: list[Message] | Message, - request_config: Optional[SendRequestConfig] = None, + messages: Union[list[Message], Message], + request_config: Optional[SendRequestConfig] = None ): payload = [] - if isinstance(messages, list): - for value in messages: - payload.append(value) - else: + if isinstance(messages, Message): payload.append(messages) - pass + elif isinstance(messages, list): + payload.extend(messages) if len(payload) == 0: - raise Exception("데이터가 반드시 1건 이상 기입되어 있어야 합니다.") + raise ValueError("데이터가 반드시 1건 이상 기입되어 있어야 합니다.") - parameter: MultipleDetailMessageSendingRequest = {"messages": payload} - if request_config["appId"] is not None: - parameter.appId = request_config["appId"] - if request_config["showMessageList"] is not None: - parameter.showMessageList = request_config["showMessageList"] - if request_config["scheduledDate"] is not None: - parameter.scheduledDate = format_with_transfer( - request_config["scheduledDate"] - ) - - response = await default_fetcher( - self._auth_info, - { - "method": RequestMethod("POST"), - "url": f"${self.baseUrl}/messages/v4/send-many/detail", - }, - parameter, + request = SendMessageRequest( + messages=payload, + app_id=request_config.app_id, + allow_duplicates=request_config.allow_duplicates, + show_message_list=request_config.show_message_list, ) - validated_response = DetailGroupMessageResponse.model_validate(response) - count = validated_response.group_info.count - if ( - len(validated_response.failed_message_list) > 0 - and count.total == count.registeredFailed - ): - raise Exception("") - - return validated_response - + # TODO: 일어나면 여기부터 시작! + if request_config.scheduled_date != "": + request.scheduled_date = format_with_transfer(request_config.scheduled_date) + + return default_fetcher(self._auth_info, request={ + "method": RequestMethod.POST, + "url": f"{self.baseUrl}/messages/v4/send-many/detail" + }, data=request.model_dump()) diff --git a/solapi/model/fax.py b/solapi/model/fax.py new file mode 100644 index 0000000..bc6bf36 --- /dev/null +++ b/solapi/model/fax.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class FileIdsType(BaseModel): + file_ids: list[str] \ No newline at end of file diff --git a/solapi/model/message.py b/solapi/model/message.py index 771cdae..d4a598e 100644 --- a/solapi/model/message.py +++ b/solapi/model/message.py @@ -1,26 +1,13 @@ from datetime import datetime from enum import Enum -from typing import Any, Optional, TypedDict, Union +from typing import Any, Optional from pydantic import BaseModel, Field +from solapi.model.fax import FileIdsType -# 외부 파일 import 대신 타입 정의 -class KakaoOption: - def __init__(self, options: dict[str, Any] = None): - self.options = options or {} - -class RcsOption: - def __init__(self, options: dict[str, Any] = None): - self.options = options or {} - - -class FileIds(TypedDict): - fileIds: list[str] - - -class MessageType(str, Enum): +class MessageType(Enum): """ 메시지 유형(단문 문자, 장문 문자, 알림톡 등) SMS: 단문 문자 @@ -39,7 +26,6 @@ class MessageType(str, Enum): FAX: 팩스 VOICE: 음성문자(TTS) """ - SMS = "SMS" LMS = "LMS" MMS = "MMS" @@ -57,181 +43,26 @@ class MessageType(str, Enum): VOICE = "VOICE" -# TODO: 생성자 만들 때 Request 딕셔너리가 아니라 Response 딕셔너리로 받아와야함. class Message(BaseModel): - _to: str - from__: Optional[str] = Field(None, alias="from") - _text: Optional[str] = None - _date_created: Optional[datetime] = None - _date_updated: Optional[datetime] = None - _group_id: Optional[str] = None - _message_id: Optional[str] = None - _image_id: Optional[str] = None - _log: Optional[str] = None - _type: Optional[str] = None - _subject: Optional[str] = None - # TODO: kakao options, rcs options 타이핑 해야 함 - _kakao_options: Optional[dict[str, Any]] = None - _rcs_options: Optional[dict[str, Any]] = None - _country: Optional[str] = None - _auto_type_detect: Optional[bool] = None - _status_code: Optional[str] = None - _replacements: Optional[dict[str, Any]] = None - _custom_fields: Optional[dict[str, Any]] = None - _fax_options: Optional[dict[str, Any]] = None - - - @property - def to(self) -> Union[str, list[str]]: - return self._to - - @to.setter - def to(self, value: Union[str, list[str]]) -> None: - self._to = value - - @property - def from_(self) -> Optional[str]: - return self.__dict__.get("from") - - @from_.setter - def from_(self, value: Optional[str]) -> None: - self.from__ = value - - @property - def text(self) -> Optional[str]: - return self._text - - @text.setter - def text(self, value: Optional[str]) -> None: - self._text = value - - @property - def date_created(self) -> Optional[str]: - return self._date_created - - @date_created.setter - def date_created(self, value: Optional[str]) -> None: - self._date_created = value - - @property - def date_updated(self) -> Optional[str]: - return self._date_updated - - @date_updated.setter - def date_updated(self, value: Optional[str]) -> None: - self._date_updated = value - - @property - def group_id(self) -> Optional[str]: - return self._group_id - - @group_id.setter - def group_id(self, value: Optional[str]) -> None: - self._group_id = value - - @property - def message_id(self) -> Optional[str]: - return self._message_id - - @message_id.setter - def message_id(self, value: Optional[str]) -> None: - self._message_id = value - - @property - def image_id(self) -> Optional[str]: - return self._image_id - - @image_id.setter - def image_id(self, value: Optional[str]) -> None: - self._image_id = value - - @property - def type(self) -> Optional[MessageType]: - return self._type - - @type.setter - def type(self, value: Optional[MessageType]) -> None: - self._type = value - - @property - def subject(self) -> Optional[str]: - return self._subject - - @subject.setter - def subject(self, value: Optional[str]) -> None: - self._subject = value - - @property - def auto_type_detect(self) -> Optional[bool]: - return self._auto_type_detect - - @auto_type_detect.setter - def auto_type_detect(self, value: Optional[bool]) -> None: - self._auto_type_detect = value - - @property - def kakao_options(self) -> Optional[KakaoOption]: - return self._kakao_options - - @kakao_options.setter - def kakao_options(self, value: Optional[KakaoOption]) -> None: - self._kakao_options = value - - @property - def rcs_options(self) -> Optional[RcsOption]: - return self._rcs_options - - @rcs_options.setter - def rcs_options(self, value: Optional[RcsOption]) -> None: - self._rcs_options = value - - @property - def country(self) -> Optional[str]: - return self._country - - @country.setter - def country(self, value: Optional[str]) -> None: - self._country = value - - @property - def log(self) -> Optional[list[dict[str, Any]]]: - return self._log - - @log.setter - def log(self, value: Optional[list[dict[str, Any]]]) -> None: - self._log = value - - @property - def replacements(self) -> Optional[list[dict[str, Any]]]: - return self._replacements - - @replacements.setter - def replacements(self, value: Optional[list[dict[str, Any]]]) -> None: - self._replacements = value - - @property - def status_code(self) -> Optional[str]: - return self._status_code - - @status_code.setter - def status_code(self, value: Optional[str]) -> None: - self._status_code = value - - @property - def custom_fields(self) -> Optional[dict[str, str]]: - return self._custom_fields - - @custom_fields.setter - def custom_fields(self, value: Optional[dict[str, str]]) -> None: - self._custom_fields = value - - @property - def fax_options(self) -> Optional[FileIds]: - return self._fax_options + from_: Optional[str] = Field(default=None, serialization_alias="from") + to: str + text: Optional[str] = None + image_id: Optional[str] = Field(default=None, serialization_alias="imageId") + country: str = "82" + message_id: Optional[str] = Field(default=None, serialization_alias="messageId") + group_id: Optional[str] = Field(default=None, serialization_alias="groupId") + type: Optional[MessageType] = None + auto_type_detect: Optional[bool] = Field(default=False, serialization_alias="autoTypeDetect") + date_created: Optional[datetime] = Field(default=None, serialization_alias="dateCreated") + date_updated: Optional[datetime] = Field(default=None, serialization_alias="dateUpdated") + subject: Optional[str] = None + log: Optional[list[dict[str, Any]]] = None + replacements: Optional[list[dict[str, Any]]] = None + status_code: Optional[str] = Field(default=None, serialization_alias="statusCode") + custom_fields: Optional[dict[str, str]] = Field(default=None, serialization_alias="customFields") + # TODO: kakaoOptions Model 정의해야 함.. + kakao_options: Optional[dict[str, Any]] = Field(default=None, serialization_alias="kakaoOptions") + rcs_options: Optional[dict[str, Any]] = Field(default=None, serialization_alias="rcsOptions") + fax_options: Optional[FileIdsType] = Field(default=None, serialization_alias="faxOptions") - @fax_options.setter - def fax_options(self, value: Optional[FileIds]) -> None: - self._fax_options = value - class Config: - populate_by_name = True diff --git a/solapi/requests/__init__.py b/solapi/model/request/__init__.py similarity index 100% rename from solapi/requests/__init__.py rename to solapi/model/request/__init__.py diff --git a/solapi/model/request/send_message_request.py b/solapi/model/request/send_message_request.py new file mode 100644 index 0000000..ed1f264 --- /dev/null +++ b/solapi/model/request/send_message_request.py @@ -0,0 +1,28 @@ +import platform +from typing import Optional + +from pydantic import BaseModel, Field + +from solapi.model.message import Message + + +class SendRequestConfig(BaseModel): + app_id: Optional[str] = Field(default=None, serialization_alias="appId") + allow_duplicates: bool = Field(default=False, serialization_alias="allowDuplicates") + show_message_list: bool = Field(default=False, serialization_alias="showMessageList") + scheduled_date: Optional[str] = Field(default=None, serialization_alias="scheduledDate") + + +class SendMessageRequest(BaseModel): + messages: list[Message] + scheduled_date: Optional[str] = Field(default=None, serialization_alias="scheduled_date") + show_message_list: Optional[bool] = Field(default=False, serialization_alias="showMessageList") + allow_duplicates: bool = Field(default=False, serialization_alias="allowDuplicates") + app_id: Optional[str] = Field(default=None, serialization_alias="appId") + + # NOTE: Python SDK 버전 업데이트 할 때마다 변경해줘야 함 + agent: dict[str, str] = { + "sdkVersion": 'python/5.0.0', + "osPlatform": f"{platform.platform()} | {platform.python_version()}" + } + \ No newline at end of file diff --git a/solapi/requests/request_confiog.py b/solapi/requests/request_confiog.py deleted file mode 100644 index 32c8f4c..0000000 --- a/solapi/requests/request_confiog.py +++ /dev/null @@ -1,18 +0,0 @@ -from datetime import datetime -from typing import TypedDict - - -class SendRequestConfig(TypedDict, total=False): - # 예약일시 - scheduledDate: str | datetime - - # 중복 수신번호 허용 여부 - # 값 미기입시 중복 수신번호 비허용이 기본값으로 설정됩니다. - allowDuplicates: bool - - # 특정 solapi app을 통해 발송 시 넣어줘야 할 app ID 값 - appId: str - - # send 메소드를 통해 발송 시 response에 messageList 값을 표시할 지에 대한 여부 - # 값 미기입시 기본값으로 비표시로 설정됩니다. - showMessageList: bool diff --git a/solapi/requests/send/__init__.py b/solapi/requests/send/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/solapi/requests/send/send_request.py b/solapi/requests/send/send_request.py deleted file mode 100644 index 1c206f1..0000000 --- a/solapi/requests/send/send_request.py +++ /dev/null @@ -1,79 +0,0 @@ -import platform -from typing import Any, TypedDict, Union - -from solapi.model.message import Message, MessageType - - -class MessageParameterRequired(TypedDict): - to: Union[str, list[str]] - - -MessageParameterNonRequired = TypedDict( - "MessageParameterNonRequired", - { - "from": str, - "text": str, - "imageId": str, - "type": MessageType, - "subject": str, - "autoTypeDetect": bool, - "kakaoOptions": dict[str, Any], - "rcsOptions": dict[str, Any], - "country": str, - "customFields": dict[str, str], - "replacements": list[object], - "faxOptions": dict[str, Any], - }, - total=False, -) - - -class SdkInfo(TypedDict): - sdkVersion: str - osPlatform: str - - -class AppId(TypedDict, total=False): - appId: str - - -class DefaultAgentType(SdkInfo, AppId): - pass - - -# 필요한 타입 정의 (실제 환경에 맞게 구체화 가능) -RcsOptionRequest = dict[str, Any] -FileIds = dict[str, Any] - - -class KakaoOptionRequest(TypedDict): - pass - - -class MessageParameter(MessageParameterRequired, MessageParameterNonRequired): - pass - - -class DefaultMessageRequest(TypedDict): - allowDuplicates: bool - agent: DefaultAgentType - - -default_agent: DefaultAgentType = { - "sdkVersion": "python/5.0.0", - "osPlatform": f"{platform.platform()} | {platform.python_version()}", -} - - -class ScheduledDateType(TypedDict, total=False): - scheduledDate: str - - -class ShowMessageListType(TypedDict, total=False): - showMessageList: bool - - -class MultipleDetailMessageSendingRequest( - TypedDict, DefaultMessageRequest, AppId, ShowMessageListType -): - messages: list[Message] diff --git a/solapi/responses/common_response.py b/solapi/responses/common_response.py deleted file mode 100644 index aebf9c6..0000000 --- a/solapi/responses/common_response.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import Any, Optional - -from pydantic import BaseModel, Field - - -class GroupMessageResponse(BaseModel): - count: Any = Field(..., alias="count") - count_for_charge: Any = Field(..., alias="countForCharge") - balance: Any = Field(..., alias="balance") - point: Any = Field(..., alias="point") - app: Any = Field(..., alias="app") - log: Any = Field(..., alias="log") - status: str = Field(..., alias="status") - allow_duplicates: bool = Field(..., alias="allowDuplicates") - is_refunded: bool = Field(..., alias="isRefunded") - account_id: str = Field(..., alias="accountId") - master_account_id: Optional[str] = Field(None, alias="masterAccountId") - api_version: str = Field(..., alias="apiVersion") - group_id: str = Field(..., alias="groupId") - price: dict[str, Any] = Field(..., alias="price") - date_created: str = Field(..., alias="dateCreated") - date_updated: str = Field(..., alias="dateUpdated") - scheduled_date: Optional[str] = Field(None, alias="scheduledDate") - date_sent: Optional[str] = Field(None, alias="dateSent") - date_completed: Optional[str] = Field(None, alias="dateCompleted") - - class Config: - populate_by_name = True - -class Count(BaseModel): - total: int = Field(..., alias="total") - sent_total: int = Field(..., alias="sentTotal") - sent_failed: int = Field(..., alias="sentFailed") - sent_success: int = Field(..., alias="sentSuccess") - sent_pending: int = Field(..., alias="sentPending") - sent_replacement: int = Field(..., alias="sentReplacement") - refund: int = Field(..., alias="refund") - registered_failed: int = Field(..., alias="registeredFailed") - registered_success: int = Field(..., alias="registeredSuccess") - - class Config: - populate_by_name = True diff --git a/solapi/responses/send/send_repsonse.py b/solapi/responses/send/send_repsonse.py deleted file mode 100644 index f1b1436..0000000 --- a/solapi/responses/send/send_repsonse.py +++ /dev/null @@ -1,40 +0,0 @@ -from typing import Optional - -from pydantic import BaseModel, Field - -from solapi.responses.common_response import GroupMessageResponse - - -class FailedMessage(BaseModel): - to: str = Field(..., alias="to") - # 'from'는 예약어이므로 내부 변수는 from_로 사용하고, alias로 "from"을 매핑합니다. - from_: str = Field(..., alias="from") - type: str = Field(..., alias="type") - status_message: str = Field(..., alias="statusMessage") - country: str = Field(..., alias="country") - message_id: str = Field(..., alias="messageId") - status_code: str = Field(..., alias="statusCode") - account_id: str = Field(..., alias="accountId") - custom_fields: Optional[dict[str, str]] = Field(None, alias="customFields") - - class Config: - populate_by_name = True - - -class MessageResponseItem(BaseModel): - message_id: str = Field(..., alias="messageId") - status_code: str = Field(..., alias="statusCode") - custom_fields: Optional[dict[str, str]] = Field(None, alias="customFields") - status_message: str = Field(..., alias="statusMessage") - - class Config: - populate_by_name = True - - -class DetailGroupMessageResponse(BaseModel): - failed_message_list: list[FailedMessage] = Field(..., alias="failedMessageList") - group_info: GroupMessageResponse = Field(..., alias="groupInfo") - message_list: Optional[list[MessageResponseItem]] = Field(None, alias="messageList") - - class Config: - populate_by_name = True diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..eda30f5 --- /dev/null +++ b/uv.lock @@ -0,0 +1,267 @@ +version = 1 +revision = 1 +requires-python = ">=3.9" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, +] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "httpcore" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "pydantic" +version = "2.10.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 }, +] + +[[package]] +name = "pydantic-core" +version = "2.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938 }, + { url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684 }, + { url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169 }, + { url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227 }, + { url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695 }, + { url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662 }, + { url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370 }, + { url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813 }, + { url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287 }, + { url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414 }, + { url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301 }, + { url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685 }, + { url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876 }, + { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421 }, + { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998 }, + { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167 }, + { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071 }, + { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244 }, + { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470 }, + { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291 }, + { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613 }, + { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355 }, + { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661 }, + { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261 }, + { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361 }, + { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484 }, + { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102 }, + { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, + { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, + { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, + { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, + { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, + { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, + { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, + { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, + { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, + { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, + { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, + { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, + { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, + { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, + { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, + { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, + { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, + { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, + { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, + { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, + { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, + { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, + { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, + { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, + { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, + { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, + { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, + { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, + { url = "https://files.pythonhosted.org/packages/27/97/3aef1ddb65c5ccd6eda9050036c956ff6ecbfe66cb7eb40f280f121a5bb0/pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993", size = 1896475 }, + { url = "https://files.pythonhosted.org/packages/ad/d3/5668da70e373c9904ed2f372cb52c0b996426f302e0dee2e65634c92007d/pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308", size = 1772279 }, + { url = "https://files.pythonhosted.org/packages/8a/9e/e44b8cb0edf04a2f0a1f6425a65ee089c1d6f9c4c2dcab0209127b6fdfc2/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4", size = 1829112 }, + { url = "https://files.pythonhosted.org/packages/1c/90/1160d7ac700102effe11616e8119e268770f2a2aa5afb935f3ee6832987d/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf", size = 1866780 }, + { url = "https://files.pythonhosted.org/packages/ee/33/13983426df09a36d22c15980008f8d9c77674fc319351813b5a2739b70f3/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76", size = 2037943 }, + { url = "https://files.pythonhosted.org/packages/01/d7/ced164e376f6747e9158c89988c293cd524ab8d215ae4e185e9929655d5c/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118", size = 2740492 }, + { url = "https://files.pythonhosted.org/packages/8b/1f/3dc6e769d5b7461040778816aab2b00422427bcaa4b56cc89e9c653b2605/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630", size = 1995714 }, + { url = "https://files.pythonhosted.org/packages/07/d7/a0bd09bc39283530b3f7c27033a814ef254ba3bd0b5cfd040b7abf1fe5da/pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54", size = 1997163 }, + { url = "https://files.pythonhosted.org/packages/2d/bb/2db4ad1762e1c5699d9b857eeb41959191980de6feb054e70f93085e1bcd/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f", size = 2005217 }, + { url = "https://files.pythonhosted.org/packages/53/5f/23a5a3e7b8403f8dd8fc8a6f8b49f6b55c7d715b77dcf1f8ae919eeb5628/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362", size = 2127899 }, + { url = "https://files.pythonhosted.org/packages/c2/ae/aa38bb8dd3d89c2f1d8362dd890ee8f3b967330821d03bbe08fa01ce3766/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96", size = 2155726 }, + { url = "https://files.pythonhosted.org/packages/98/61/4f784608cc9e98f70839187117ce840480f768fed5d386f924074bf6213c/pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e", size = 1817219 }, + { url = "https://files.pythonhosted.org/packages/57/82/bb16a68e4a1a858bb3768c2c8f1ff8d8978014e16598f001ea29a25bf1d1/pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67", size = 1985382 }, + { url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159 }, + { url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331 }, + { url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467 }, + { url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797 }, + { url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839 }, + { url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861 }, + { url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582 }, + { url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985 }, + { url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715 }, + { url = "https://files.pythonhosted.org/packages/29/0e/dcaea00c9dbd0348b723cae82b0e0c122e0fa2b43fa933e1622fd237a3ee/pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656", size = 1891733 }, + { url = "https://files.pythonhosted.org/packages/86/d3/e797bba8860ce650272bda6383a9d8cad1d1c9a75a640c9d0e848076f85e/pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278", size = 1768375 }, + { url = "https://files.pythonhosted.org/packages/41/f7/f847b15fb14978ca2b30262548f5fc4872b2724e90f116393eb69008299d/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb", size = 1822307 }, + { url = "https://files.pythonhosted.org/packages/9c/63/ed80ec8255b587b2f108e514dc03eed1546cd00f0af281e699797f373f38/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd", size = 1979971 }, + { url = "https://files.pythonhosted.org/packages/a9/6d/6d18308a45454a0de0e975d70171cadaf454bc7a0bf86b9c7688e313f0bb/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc", size = 1987616 }, + { url = "https://files.pythonhosted.org/packages/82/8a/05f8780f2c1081b800a7ca54c1971e291c2d07d1a50fb23c7e4aef4ed403/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b", size = 1998943 }, + { url = "https://files.pythonhosted.org/packages/5e/3e/fe5b6613d9e4c0038434396b46c5303f5ade871166900b357ada4766c5b7/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b", size = 2116654 }, + { url = "https://files.pythonhosted.org/packages/db/ad/28869f58938fad8cc84739c4e592989730bfb69b7c90a8fff138dff18e1e/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2", size = 2152292 }, + { url = "https://files.pythonhosted.org/packages/a1/0c/c5c5cd3689c32ed1fe8c5d234b079c12c281c051759770c05b8bed6412b5/pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35", size = 2004961 }, +] + +[[package]] +name = "ruff" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/2b/7ca27e854d92df5e681e6527dc0f9254c9dc06c8408317893cf96c851cdd/ruff-0.11.0.tar.gz", hash = "sha256:e55c620690a4a7ee6f1cccb256ec2157dc597d109400ae75bbf944fc9d6462e2", size = 3799407 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/40/3d0340a9e5edc77d37852c0cd98c5985a5a8081fc3befaeb2ae90aaafd2b/ruff-0.11.0-py3-none-linux_armv6l.whl", hash = "sha256:dc67e32bc3b29557513eb7eeabb23efdb25753684b913bebb8a0c62495095acb", size = 10098158 }, + { url = "https://files.pythonhosted.org/packages/ec/a9/d8f5abb3b87b973b007649ac7bf63665a05b2ae2b2af39217b09f52abbbf/ruff-0.11.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:38c23fd9bdec4eb437b4c1e3595905a0a8edfccd63a790f818b28c78fe345639", size = 10879071 }, + { url = "https://files.pythonhosted.org/packages/ab/62/aaa198614c6211677913ec480415c5e6509586d7b796356cec73a2f8a3e6/ruff-0.11.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7c8661b0be91a38bd56db593e9331beaf9064a79028adee2d5f392674bbc5e88", size = 10247944 }, + { url = "https://files.pythonhosted.org/packages/9f/52/59e0a9f2cf1ce5e6cbe336b6dd0144725c8ea3b97cac60688f4e7880bf13/ruff-0.11.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6c0e8d3d2db7e9f6efd884f44b8dc542d5b6b590fc4bb334fdbc624d93a29a2", size = 10421725 }, + { url = "https://files.pythonhosted.org/packages/a6/c3/dcd71acc6dff72ce66d13f4be5bca1dbed4db678dff2f0f6f307b04e5c02/ruff-0.11.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c3156d3f4b42e57247275a0a7e15a851c165a4fc89c5e8fa30ea6da4f7407b8", size = 9954435 }, + { url = "https://files.pythonhosted.org/packages/a6/9a/342d336c7c52dbd136dee97d4c7797e66c3f92df804f8f3b30da59b92e9c/ruff-0.11.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:490b1e147c1260545f6d041c4092483e3f6d8eba81dc2875eaebcf9140b53905", size = 11492664 }, + { url = "https://files.pythonhosted.org/packages/84/35/6e7defd2d7ca95cc385ac1bd9f7f2e4a61b9cc35d60a263aebc8e590c462/ruff-0.11.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1bc09a7419e09662983b1312f6fa5dab829d6ab5d11f18c3760be7ca521c9329", size = 12207856 }, + { url = "https://files.pythonhosted.org/packages/22/78/da669c8731bacf40001c880ada6d31bcfb81f89cc996230c3b80d319993e/ruff-0.11.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcfa478daf61ac8002214eb2ca5f3e9365048506a9d52b11bea3ecea822bb844", size = 11645156 }, + { url = "https://files.pythonhosted.org/packages/ee/47/e27d17d83530a208f4a9ab2e94f758574a04c51e492aa58f91a3ed7cbbcb/ruff-0.11.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6fbb2aed66fe742a6a3a0075ed467a459b7cedc5ae01008340075909d819df1e", size = 13884167 }, + { url = "https://files.pythonhosted.org/packages/9f/5e/42ffbb0a5d4b07bbc642b7d58357b4e19a0f4774275ca6ca7d1f7b5452cd/ruff-0.11.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:92c0c1ff014351c0b0cdfdb1e35fa83b780f1e065667167bb9502d47ca41e6db", size = 11348311 }, + { url = "https://files.pythonhosted.org/packages/c8/51/dc3ce0c5ce1a586727a3444a32f98b83ba99599bb1ebca29d9302886e87f/ruff-0.11.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e4fd5ff5de5f83e0458a138e8a869c7c5e907541aec32b707f57cf9a5e124445", size = 10305039 }, + { url = "https://files.pythonhosted.org/packages/60/e0/475f0c2f26280f46f2d6d1df1ba96b3399e0234cf368cc4c88e6ad10dcd9/ruff-0.11.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:96bc89a5c5fd21a04939773f9e0e276308be0935de06845110f43fd5c2e4ead7", size = 9937939 }, + { url = "https://files.pythonhosted.org/packages/e2/d3/3e61b7fd3e9cdd1e5b8c7ac188bec12975c824e51c5cd3d64caf81b0331e/ruff-0.11.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a9352b9d767889ec5df1483f94870564e8102d4d7e99da52ebf564b882cdc2c7", size = 10923259 }, + { url = "https://files.pythonhosted.org/packages/30/32/cd74149ebb40b62ddd14bd2d1842149aeb7f74191fb0f49bd45c76909ff2/ruff-0.11.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:049a191969a10897fe052ef9cc7491b3ef6de79acd7790af7d7897b7a9bfbcb6", size = 11406212 }, + { url = "https://files.pythonhosted.org/packages/00/ef/033022a6b104be32e899b00de704d7c6d1723a54d4c9e09d147368f14b62/ruff-0.11.0-py3-none-win32.whl", hash = "sha256:3191e9116b6b5bbe187447656f0c8526f0d36b6fd89ad78ccaad6bdc2fad7df2", size = 10310905 }, + { url = "https://files.pythonhosted.org/packages/ed/8a/163f2e78c37757d035bd56cd60c8d96312904ca4a6deeab8442d7b3cbf89/ruff-0.11.0-py3-none-win_amd64.whl", hash = "sha256:c58bfa00e740ca0a6c43d41fb004cd22d165302f360aaa56f7126d544db31a21", size = 11411730 }, + { url = "https://files.pythonhosted.org/packages/4e/f7/096f6efabe69b49d7ca61052fc70289c05d8d35735c137ef5ba5ef423662/ruff-0.11.0-py3-none-win_arm64.whl", hash = "sha256:868364fc23f5aa122b00c6f794211e85f7e78f5dffdf7c590ab90b8c4e69b657", size = 10538956 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "solapi" +version = "5.0.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] + +[package.optional-dependencies] +dev = [ + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.28.1,<0.29.0" }, + { name = "pydantic", specifier = ">=2.10.6,<3.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] From 3392f70925393c06e4e19112d2630debd81141b2 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 17 Mar 2025 09:07:08 +0900 Subject: [PATCH 06/60] [Chore] Update files --- examples/sms/sms.py | 4 ++-- solapi/lib/string_date_transfer.py | 3 ++- solapi/message_service.py | 19 +++++++++++-------- solapi/model/fax.py | 5 ----- solapi/model/message.py | 8 +++----- solapi/model/request/send_message_request.py | 2 +- 6 files changed, 19 insertions(+), 22 deletions(-) delete mode 100644 solapi/model/fax.py diff --git a/examples/sms/sms.py b/examples/sms/sms.py index 759d9dd..a0c38cb 100644 --- a/examples/sms/sms.py +++ b/examples/sms/sms.py @@ -2,9 +2,9 @@ from solapi.message_service import SolapiMessageService from solapi.model.message import Message -test = SolapiMessageService(api_key="", api_secret="") +test = SolapiMessageService(api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET") test2 = Message( - from_="029302266", + from_="01012345678", to="01012345678", text="test" ) diff --git a/solapi/lib/string_date_transfer.py b/solapi/lib/string_date_transfer.py index db8d301..4705a1d 100644 --- a/solapi/lib/string_date_transfer.py +++ b/solapi/lib/string_date_transfer.py @@ -1,5 +1,6 @@ import re from datetime import datetime +from typing import Union class InvalidDateError(Exception): @@ -60,7 +61,7 @@ def parse_iso(date_string: str) -> datetime: raise InvalidDateError("Invalid Date") from e -def string_date_transfer(value: str | datetime): +def string_date_transfer(value: Union[str, datetime]): """ 일반 문자열 날짜가 있을 경우 datetime 타입으로 변환해주는 함수 diff --git a/solapi/message_service.py b/solapi/message_service.py index a0e237a..5d91068 100644 --- a/solapi/message_service.py +++ b/solapi/message_service.py @@ -33,16 +33,19 @@ def send( raise ValueError("데이터가 반드시 1건 이상 기입되어 있어야 합니다.") request = SendMessageRequest( - messages=payload, - app_id=request_config.app_id, - allow_duplicates=request_config.allow_duplicates, - show_message_list=request_config.show_message_list, + messages=payload ) - # TODO: 일어나면 여기부터 시작! - if request_config.scheduled_date != "": - request.scheduled_date = format_with_transfer(request_config.scheduled_date) + if request_config is not None: + request.app_id = request_config["app_id"] + request.allow_duplicates = request_config["allow_duplicates"] + request.show_message_list = request_config["show_message_list"] + + if request_config.scheduled_date != "": + request.scheduled_date = format_with_transfer(request_config.scheduled_date) + + print(request.model_dump(exclude_none=True, by_alias=True)) return default_fetcher(self._auth_info, request={ "method": RequestMethod.POST, "url": f"{self.baseUrl}/messages/v4/send-many/detail" - }, data=request.model_dump()) + }, data=request.model_dump(exclude_none=True, by_alias=True)) diff --git a/solapi/model/fax.py b/solapi/model/fax.py deleted file mode 100644 index bc6bf36..0000000 --- a/solapi/model/fax.py +++ /dev/null @@ -1,5 +0,0 @@ -from pydantic import BaseModel - - -class FileIdsType(BaseModel): - file_ids: list[str] \ No newline at end of file diff --git a/solapi/model/message.py b/solapi/model/message.py index d4a598e..f3eec32 100644 --- a/solapi/model/message.py +++ b/solapi/model/message.py @@ -4,8 +4,6 @@ from pydantic import BaseModel, Field -from solapi.model.fax import FileIdsType - class MessageType(Enum): """ @@ -42,6 +40,8 @@ class MessageType(Enum): FAX = "FAX" VOICE = "VOICE" +class FileIdsType(BaseModel): + file_ids: list[str] class Message(BaseModel): from_: Optional[str] = Field(default=None, serialization_alias="from") @@ -52,7 +52,7 @@ class Message(BaseModel): message_id: Optional[str] = Field(default=None, serialization_alias="messageId") group_id: Optional[str] = Field(default=None, serialization_alias="groupId") type: Optional[MessageType] = None - auto_type_detect: Optional[bool] = Field(default=False, serialization_alias="autoTypeDetect") + auto_type_detect: Optional[bool] = Field(default=True, serialization_alias="autoTypeDetect") date_created: Optional[datetime] = Field(default=None, serialization_alias="dateCreated") date_updated: Optional[datetime] = Field(default=None, serialization_alias="dateUpdated") subject: Optional[str] = None @@ -64,5 +64,3 @@ class Message(BaseModel): kakao_options: Optional[dict[str, Any]] = Field(default=None, serialization_alias="kakaoOptions") rcs_options: Optional[dict[str, Any]] = Field(default=None, serialization_alias="rcsOptions") fax_options: Optional[FileIdsType] = Field(default=None, serialization_alias="faxOptions") - - diff --git a/solapi/model/request/send_message_request.py b/solapi/model/request/send_message_request.py index ed1f264..235f1cf 100644 --- a/solapi/model/request/send_message_request.py +++ b/solapi/model/request/send_message_request.py @@ -15,7 +15,7 @@ class SendRequestConfig(BaseModel): class SendMessageRequest(BaseModel): messages: list[Message] - scheduled_date: Optional[str] = Field(default=None, serialization_alias="scheduled_date") + scheduled_date: Optional[str] = Field(default=None, serialization_alias="scheduledDate") show_message_list: Optional[bool] = Field(default=False, serialization_alias="showMessageList") allow_duplicates: bool = Field(default=False, serialization_alias="allowDuplicates") app_id: Optional[str] = Field(default=None, serialization_alias="appId") From 5687d847be47ead5e404d512fc67a1efde66e50f Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 17 Mar 2025 09:09:05 +0900 Subject: [PATCH 07/60] [Chore] format files --- examples/sms/sms.py | 7 +---- solapi/message_service.py | 22 ++++++++------ solapi/model/message.py | 31 +++++++++++++++----- solapi/model/request/send_message_request.py | 23 ++++++++++----- 4 files changed, 53 insertions(+), 30 deletions(-) diff --git a/examples/sms/sms.py b/examples/sms/sms.py index a0c38cb..8c32e82 100644 --- a/examples/sms/sms.py +++ b/examples/sms/sms.py @@ -1,11 +1,6 @@ - from solapi.message_service import SolapiMessageService from solapi.model.message import Message test = SolapiMessageService(api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET") -test2 = Message( - from_="01012345678", - to="01012345678", - text="test" -) +test2 = Message(from_="01012345678", to="01012345678", text="test") test.send(test2) diff --git a/solapi/message_service.py b/solapi/message_service.py index 5d91068..4acc9dc 100644 --- a/solapi/message_service.py +++ b/solapi/message_service.py @@ -21,7 +21,7 @@ def __init__(self, api_key: str, api_secret: str): def send( self, messages: Union[list[Message], Message], - request_config: Optional[SendRequestConfig] = None + request_config: Optional[SendRequestConfig] = None, ): payload = [] if isinstance(messages, Message): @@ -32,20 +32,24 @@ def send( if len(payload) == 0: raise ValueError("데이터가 반드시 1건 이상 기입되어 있어야 합니다.") - request = SendMessageRequest( - messages=payload - ) + request = SendMessageRequest(messages=payload) if request_config is not None: request.app_id = request_config["app_id"] request.allow_duplicates = request_config["allow_duplicates"] request.show_message_list = request_config["show_message_list"] if request_config.scheduled_date != "": - request.scheduled_date = format_with_transfer(request_config.scheduled_date) + request.scheduled_date = format_with_transfer( + request_config.scheduled_date + ) print(request.model_dump(exclude_none=True, by_alias=True)) - return default_fetcher(self._auth_info, request={ - "method": RequestMethod.POST, - "url": f"{self.baseUrl}/messages/v4/send-many/detail" - }, data=request.model_dump(exclude_none=True, by_alias=True)) + return default_fetcher( + self._auth_info, + request={ + "method": RequestMethod.POST, + "url": f"{self.baseUrl}/messages/v4/send-many/detail", + }, + data=request.model_dump(exclude_none=True, by_alias=True), + ) diff --git a/solapi/model/message.py b/solapi/model/message.py index f3eec32..517f2d9 100644 --- a/solapi/model/message.py +++ b/solapi/model/message.py @@ -24,6 +24,7 @@ class MessageType(Enum): FAX: 팩스 VOICE: 음성문자(TTS) """ + SMS = "SMS" LMS = "LMS" MMS = "MMS" @@ -40,9 +41,11 @@ class MessageType(Enum): FAX = "FAX" VOICE = "VOICE" + class FileIdsType(BaseModel): file_ids: list[str] + class Message(BaseModel): from_: Optional[str] = Field(default=None, serialization_alias="from") to: str @@ -52,15 +55,29 @@ class Message(BaseModel): message_id: Optional[str] = Field(default=None, serialization_alias="messageId") group_id: Optional[str] = Field(default=None, serialization_alias="groupId") type: Optional[MessageType] = None - auto_type_detect: Optional[bool] = Field(default=True, serialization_alias="autoTypeDetect") - date_created: Optional[datetime] = Field(default=None, serialization_alias="dateCreated") - date_updated: Optional[datetime] = Field(default=None, serialization_alias="dateUpdated") + auto_type_detect: Optional[bool] = Field( + default=True, serialization_alias="autoTypeDetect" + ) + date_created: Optional[datetime] = Field( + default=None, serialization_alias="dateCreated" + ) + date_updated: Optional[datetime] = Field( + default=None, serialization_alias="dateUpdated" + ) subject: Optional[str] = None log: Optional[list[dict[str, Any]]] = None replacements: Optional[list[dict[str, Any]]] = None status_code: Optional[str] = Field(default=None, serialization_alias="statusCode") - custom_fields: Optional[dict[str, str]] = Field(default=None, serialization_alias="customFields") + custom_fields: Optional[dict[str, str]] = Field( + default=None, serialization_alias="customFields" + ) # TODO: kakaoOptions Model 정의해야 함.. - kakao_options: Optional[dict[str, Any]] = Field(default=None, serialization_alias="kakaoOptions") - rcs_options: Optional[dict[str, Any]] = Field(default=None, serialization_alias="rcsOptions") - fax_options: Optional[FileIdsType] = Field(default=None, serialization_alias="faxOptions") + kakao_options: Optional[dict[str, Any]] = Field( + default=None, serialization_alias="kakaoOptions" + ) + rcs_options: Optional[dict[str, Any]] = Field( + default=None, serialization_alias="rcsOptions" + ) + fax_options: Optional[FileIdsType] = Field( + default=None, serialization_alias="faxOptions" + ) diff --git a/solapi/model/request/send_message_request.py b/solapi/model/request/send_message_request.py index 235f1cf..e08d5e6 100644 --- a/solapi/model/request/send_message_request.py +++ b/solapi/model/request/send_message_request.py @@ -9,20 +9,27 @@ class SendRequestConfig(BaseModel): app_id: Optional[str] = Field(default=None, serialization_alias="appId") allow_duplicates: bool = Field(default=False, serialization_alias="allowDuplicates") - show_message_list: bool = Field(default=False, serialization_alias="showMessageList") - scheduled_date: Optional[str] = Field(default=None, serialization_alias="scheduledDate") - + show_message_list: bool = Field( + default=False, serialization_alias="showMessageList" + ) + scheduled_date: Optional[str] = Field( + default=None, serialization_alias="scheduledDate" + ) + class SendMessageRequest(BaseModel): messages: list[Message] - scheduled_date: Optional[str] = Field(default=None, serialization_alias="scheduledDate") - show_message_list: Optional[bool] = Field(default=False, serialization_alias="showMessageList") + scheduled_date: Optional[str] = Field( + default=None, serialization_alias="scheduledDate" + ) + show_message_list: Optional[bool] = Field( + default=False, serialization_alias="showMessageList" + ) allow_duplicates: bool = Field(default=False, serialization_alias="allowDuplicates") app_id: Optional[str] = Field(default=None, serialization_alias="appId") # NOTE: Python SDK 버전 업데이트 할 때마다 변경해줘야 함 agent: dict[str, str] = { - "sdkVersion": 'python/5.0.0', - "osPlatform": f"{platform.platform()} | {platform.python_version()}" + "sdkVersion": "python/5.0.0", + "osPlatform": f"{platform.platform()} | {platform.python_version()}", } - \ No newline at end of file From 33d6a059986a8f11ff9842e0511953c9513c6a81 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 17 Mar 2025 16:59:01 +0900 Subject: [PATCH 08/60] Update serialize/deserialize models in message_service.py: * Update datetime timezone setting in string_date_transfer.py --- .gitignore | 1 + examples/sms/sms.py | 9 +- solapi/lib/fetcher.py | 3 +- solapi/lib/string_date_transfer.py | 9 +- solapi/message_service.py | 49 ++++++++--- solapi/model/kakao/__init__.py | 0 solapi/model/kakao/kakao_option.py | 0 solapi/model/message.py | 44 +++++++--- solapi/model/request/send_message_request.py | 5 +- solapi/model/response/__init__.py | 0 solapi/model/response/common_response.py | 87 +++++++++++++++++++ .../model/response/send_message_response.py | 27 ++++++ 12 files changed, 198 insertions(+), 36 deletions(-) create mode 100644 solapi/model/kakao/__init__.py create mode 100644 solapi/model/kakao/kakao_option.py create mode 100644 solapi/model/response/__init__.py create mode 100644 solapi/model/response/common_response.py create mode 100644 solapi/model/response/send_message_response.py diff --git a/.gitignore b/.gitignore index 08016bd..118a46a 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ build/ *.bak *.swp *.swo +debug # ruff .ruff_cache/ diff --git a/examples/sms/sms.py b/examples/sms/sms.py index 8c32e82..f0b298d 100644 --- a/examples/sms/sms.py +++ b/examples/sms/sms.py @@ -1,6 +1,9 @@ from solapi.message_service import SolapiMessageService from solapi.model.message import Message -test = SolapiMessageService(api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET") -test2 = Message(from_="01012345678", to="01012345678", text="test") -test.send(test2) +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) +message = Message(from_="등록한 발신번호", to="01000000000", text="Hello World!") +response = message_service.send(message) +print(response) diff --git a/solapi/lib/fetcher.py b/solapi/lib/fetcher.py index 648f103..85b3002 100644 --- a/solapi/lib/fetcher.py +++ b/solapi/lib/fetcher.py @@ -47,12 +47,11 @@ def default_fetcher( ) # 4xx 에러 처리: 클라이언트 오류일 경우 - # TODO: Custom Error 만들어야 함 if 400 <= response.status_code < 500: error_response: dict[str, Any] = response.json() raise Exception( error_response.get("errorCode", "UnknownError"), - error_response.get("errorMessage", ""), + error_response.get("errorMessage", "An Error occurred"), ) # 5xx 에러 처리: 서버 오류일 경우 elif response.status_code >= 500: diff --git a/solapi/lib/string_date_transfer.py b/solapi/lib/string_date_transfer.py index 4705a1d..164b6a2 100644 --- a/solapi/lib/string_date_transfer.py +++ b/solapi/lib/string_date_transfer.py @@ -1,5 +1,6 @@ import re -from datetime import datetime +import time +from datetime import datetime, timedelta, timezone from typing import Union @@ -24,7 +25,9 @@ def format_iso(date: datetime) -> str: Returns: ISO 8601 형식의 문자열 (예: '2023-01-01T12:00:00Z') """ - return date.isoformat() + utc_offset_sec = time.altzone if time.localtime().tm_isdst else time.timezone + utc_offset = timedelta(seconds=-utc_offset_sec) + return date.replace(tzinfo=timezone(offset=utc_offset)).isoformat() def parse_iso(date_string: str) -> datetime: @@ -83,7 +86,7 @@ def string_date_transfer(value: Union[str, datetime]): return value -def format_with_transfer(value: str) -> str: +def format_with_transfer(value: Union[str, datetime]) -> str: """ string_date_transfer와 format_iso를 한번에 실행하는 함수 diff --git a/solapi/message_service.py b/solapi/message_service.py index 4acc9dc..3d04279 100644 --- a/solapi/message_service.py +++ b/solapi/message_service.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Optional, Union from solapi.lib.authenticator import AuthenticationParameter @@ -8,12 +9,13 @@ SendMessageRequest, SendRequestConfig, ) +from solapi.model.response.send_message_response import SendMessageResponse class SolapiMessageService: def __init__(self, api_key: str, api_secret: str): - self.baseUrl = "https://api.solapi.com" - self._auth_info: AuthenticationParameter = { + self.base_url = "https://api.solapi.com" + self.auth_info: AuthenticationParameter = { "api_key": api_key, "api_secret": api_secret, } @@ -22,34 +24,55 @@ def send( self, messages: Union[list[Message], Message], request_config: Optional[SendRequestConfig] = None, - ): + ) -> SendMessageResponse: payload = [] if isinstance(messages, Message): payload.append(messages) elif isinstance(messages, list): + for message in messages: + if isinstance(message, Message) is not True: + raise TypeError( + "The messages parameter must be an instance of Message." + ) + payload.extend(messages) + else: + raise TypeError("Invalid message type") if len(payload) == 0: - raise ValueError("데이터가 반드시 1건 이상 기입되어 있어야 합니다.") + raise ValueError("The data must have at least one message.") request = SendMessageRequest(messages=payload) if request_config is not None: - request.app_id = request_config["app_id"] - request.allow_duplicates = request_config["allow_duplicates"] - request.show_message_list = request_config["show_message_list"] + request.app_id = request_config.app_id + request.allow_duplicates = request_config.allow_duplicates + request.show_message_list = request_config.show_message_list - if request_config.scheduled_date != "": + if ( + request_config.scheduled_date is not None + and request_config.scheduled_date != "" + and isinstance(request_config.scheduled_date, datetime) + ): request.scheduled_date = format_with_transfer( request_config.scheduled_date ) - print(request.model_dump(exclude_none=True, by_alias=True)) - - return default_fetcher( - self._auth_info, + response = default_fetcher( + self.auth_info, request={ "method": RequestMethod.POST, - "url": f"{self.baseUrl}/messages/v4/send-many/detail", + "url": f"{self.base_url}/messages/v4/send-many/detail", }, data=request.model_dump(exclude_none=True, by_alias=True), ) + deserialized_response: SendMessageResponse = SendMessageResponse.model_validate( + response + ) + + count = deserialized_response.count + failed_messages = deserialized_response.failed_message_list + registered_failed_count = count.registered_failed + if len(failed_messages) > 0 and count.total == registered_failed_count: + raise ValueError("an error occurred") + + return deserialized_response diff --git a/solapi/model/kakao/__init__.py b/solapi/model/kakao/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solapi/model/kakao/kakao_option.py b/solapi/model/kakao/kakao_option.py new file mode 100644 index 0000000..e69de29 diff --git a/solapi/model/message.py b/solapi/model/message.py index 517f2d9..1392b0c 100644 --- a/solapi/model/message.py +++ b/solapi/model/message.py @@ -2,7 +2,7 @@ from enum import Enum from typing import Any, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class MessageType(Enum): @@ -47,37 +47,55 @@ class FileIdsType(BaseModel): class Message(BaseModel): - from_: Optional[str] = Field(default=None, serialization_alias="from") + from_: Optional[str] = Field( + default=None, serialization_alias="from", validation_alias="from" + ) to: str text: Optional[str] = None - image_id: Optional[str] = Field(default=None, serialization_alias="imageId") + image_id: Optional[str] = Field( + default=None, serialization_alias="imageId", validation_alias="imageId" + ) country: str = "82" - message_id: Optional[str] = Field(default=None, serialization_alias="messageId") - group_id: Optional[str] = Field(default=None, serialization_alias="groupId") + message_id: Optional[str] = Field( + default=None, serialization_alias="messageId", validation_alias="messageId" + ) + group_id: Optional[str] = Field( + default=None, serialization_alias="groupId", validation_alias="groupId" + ) type: Optional[MessageType] = None auto_type_detect: Optional[bool] = Field( - default=True, serialization_alias="autoTypeDetect" + default=True, + serialization_alias="autoTypeDetect", + validation_alias="autoTypeDetect", ) date_created: Optional[datetime] = Field( - default=None, serialization_alias="dateCreated" + default=None, serialization_alias="dateCreated", validation_alias="dateCreated" ) date_updated: Optional[datetime] = Field( - default=None, serialization_alias="dateUpdated" + default=None, serialization_alias="dateUpdated", validation_alias="dateUpdated" ) subject: Optional[str] = None log: Optional[list[dict[str, Any]]] = None replacements: Optional[list[dict[str, Any]]] = None - status_code: Optional[str] = Field(default=None, serialization_alias="statusCode") + status_code: Optional[str] = Field( + default=None, serialization_alias="statusCode", validation_alias="statusCode" + ) custom_fields: Optional[dict[str, str]] = Field( - default=None, serialization_alias="customFields" + default=None, + serialization_alias="customFields", + validation_alias="customFields", ) # TODO: kakaoOptions Model 정의해야 함.. kakao_options: Optional[dict[str, Any]] = Field( - default=None, serialization_alias="kakaoOptions" + default=None, + serialization_alias="kakaoOptions", + validation_alias="kakaoOptions", ) rcs_options: Optional[dict[str, Any]] = Field( - default=None, serialization_alias="rcsOptions" + default=None, serialization_alias="rcsOptions", validation_alias="rcsOptions" ) fax_options: Optional[FileIdsType] = Field( - default=None, serialization_alias="faxOptions" + default=None, serialization_alias="faxOptions", validation_alias="faxOptions" ) + + model_config = ConfigDict(extra="ignore") diff --git a/solapi/model/request/send_message_request.py b/solapi/model/request/send_message_request.py index e08d5e6..e414b1f 100644 --- a/solapi/model/request/send_message_request.py +++ b/solapi/model/request/send_message_request.py @@ -1,5 +1,6 @@ import platform -from typing import Optional +from datetime import datetime +from typing import Optional, Union from pydantic import BaseModel, Field @@ -12,7 +13,7 @@ class SendRequestConfig(BaseModel): show_message_list: bool = Field( default=False, serialization_alias="showMessageList" ) - scheduled_date: Optional[str] = Field( + scheduled_date: Optional[Union[str, datetime]] = Field( default=None, serialization_alias="scheduledDate" ) diff --git a/solapi/model/response/__init__.py b/solapi/model/response/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solapi/model/response/common_response.py b/solapi/model/response/common_response.py new file mode 100644 index 0000000..285e272 --- /dev/null +++ b/solapi/model/response/common_response.py @@ -0,0 +1,87 @@ +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class CountResponse(BaseModel): + total: int + sent_total: int = Field(..., validation_alias="sentTotal") + sent_success: int = Field(..., validation_alias="sentSuccess") + sent_pending: int = Field(..., validation_alias="sentPending") + sent_replacement: int = Field(..., validation_alias="sentReplacement") + refund: int + registered_failed: int = Field(..., validation_alias="registeredFailed") + registered_success: int = Field(..., validation_alias="registeredSuccess") + + model_config = ConfigDict(extra="ignore") + + +class CommonCashResponse(BaseModel): + requested: int + replacement: int + refund: int + sum: int + + model_config = ConfigDict(extra="ignore") + + +class CountForChargeResponse(BaseModel): + sms: dict[str, int] + lms: dict[str, int] + mms: dict[str, int] + ata: dict[str, int] + cta: dict[str, int] + cti: dict[str, int] + nsa: dict[str, int] + rcs_sms: dict[str, int] + rcs_lms: dict[str, int] + rcs_mms: dict[str, int] + rcs_tpl: dict[str, int] + + model_config = ConfigDict(extra="ignore") + + +class GroupMessageResponse(BaseModel): + # export type GroupMessageResponse = { + # count: Count; + # countForCharge: CountForCharge; + # balance: CommonCashResponse; + # point: CommonCashResponse; + # app: App; + # log: Log; + # status: string; + # allowDuplicates: boolean; + # isRefunded: boolean; + # accountId: string; + # masterAccountId: string | null; + # apiVersion: string; + # groupId: string; + # price: object; + # dateCreated: string; + # dateUpdated: string; + # scheduledDate?: string; + # dateSent?: string; + # dateCompleted?: string; + # }; + count: CountResponse + count_for_charge: Any = Field(..., validation_alias="countForCharge") + balance: CommonCashResponse + point: CommonCashResponse + app: Any + log: Any + status: str + allow_duplicates: bool = Field(..., validation_alias="allowDuplicates") + is_refunded: bool = Field(..., validation_alias="isRefunded") + account_id: str = Field(..., validation_alias="accountId") + master_account_id: str = Field(..., validation_alias="masterAccountId") + api_version: str = Field(..., validation_alias="apiVersion") + group_id: str = Field(..., validation_alias="groupId") + price: Any + date_created: datetime = Field(..., validation_alias="dateCreated") + date_updated: datetime = Field(..., validation_alias="dateUpdated") + scheduled_date: datetime = Field(..., validation_alias="scheduledDate") + date_sent: datetime = Field(..., validation_alias="dateSent") + date_completed: datetime = Field(..., validation_alias="dateCompleted") + + model_config = ConfigDict(extra="ignore") diff --git a/solapi/model/response/send_message_response.py b/solapi/model/response/send_message_response.py new file mode 100644 index 0000000..1ce3dc3 --- /dev/null +++ b/solapi/model/response/send_message_response.py @@ -0,0 +1,27 @@ +from typing import Optional + +from pydantic import BaseModel, Field + +from solapi.model.response.common_response import GroupMessageResponse + + +class FailedMessage(BaseModel): + to: str + from_: str = Field(..., alias="from") + type: str + status_message: str = Field(..., alias="statusMessage") + country: str + message_id: str = Field(..., alias="messageId") + status_code: str = Field(..., alias="statusCode") + account_id: str = Field(..., alias="accountId") + custom_fields: Optional[dict[str, str]] = Field( + default=None, alias="customFields" + ) + + +class SendMessageResponse(BaseModel): + failed_message_list: list[FailedMessage] = Field( + ..., alias="failedMessageList" + ) + group_info: GroupMessageResponse = Field(..., alias="groupInfo") + message_list: Optional[list] = Field(default=None, alias="messageList") From c67520e409e77194c4717ae0f95a6897a1b95937 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 18 Mar 2025 14:18:40 +0900 Subject: [PATCH 09/60] Update models --- solapi/message_service.py | 3 +- solapi/model/message.py | 5 +- solapi/model/response/common_response.py | 62 +++++++------------ .../model/response/send_message_response.py | 21 ++++--- 4 files changed, 40 insertions(+), 51 deletions(-) diff --git a/solapi/message_service.py b/solapi/message_service.py index 3d04279..8e9f7a4 100644 --- a/solapi/message_service.py +++ b/solapi/message_service.py @@ -65,11 +65,12 @@ def send( }, data=request.model_dump(exclude_none=True, by_alias=True), ) + print(response) deserialized_response: SendMessageResponse = SendMessageResponse.model_validate( response ) - count = deserialized_response.count + count = deserialized_response.group_info.count failed_messages = deserialized_response.failed_message_list registered_failed_count = count.registered_failed if len(failed_messages) > 0 and count.total == registered_failed_count: diff --git a/solapi/model/message.py b/solapi/model/message.py index 1392b0c..a74d15e 100644 --- a/solapi/model/message.py +++ b/solapi/model/message.py @@ -3,6 +3,7 @@ from typing import Any, Optional from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel class MessageType(Enum): @@ -98,4 +99,6 @@ class Message(BaseModel): default=None, serialization_alias="faxOptions", validation_alias="faxOptions" ) - model_config = ConfigDict(extra="ignore") + model_config = ConfigDict( + extra="ignore", populate_by_name=True, alias_generator=to_camel + ) diff --git a/solapi/model/response/common_response.py b/solapi/model/response/common_response.py index 285e272..78029ea 100644 --- a/solapi/model/response/common_response.py +++ b/solapi/model/response/common_response.py @@ -1,7 +1,8 @@ from datetime import datetime -from typing import Any +from typing import Any, Optional from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel class CountResponse(BaseModel): @@ -18,10 +19,10 @@ class CountResponse(BaseModel): class CommonCashResponse(BaseModel): - requested: int - replacement: int - refund: int - sum: int + requested: float + replacement: float + refund: float + sum: float model_config = ConfigDict(extra="ignore") @@ -43,45 +44,28 @@ class CountForChargeResponse(BaseModel): class GroupMessageResponse(BaseModel): - # export type GroupMessageResponse = { - # count: Count; - # countForCharge: CountForCharge; - # balance: CommonCashResponse; - # point: CommonCashResponse; - # app: App; - # log: Log; - # status: string; - # allowDuplicates: boolean; - # isRefunded: boolean; - # accountId: string; - # masterAccountId: string | null; - # apiVersion: string; - # groupId: string; - # price: object; - # dateCreated: string; - # dateUpdated: string; - # scheduledDate?: string; - # dateSent?: string; - # dateCompleted?: string; - # }; count: CountResponse - count_for_charge: Any = Field(..., validation_alias="countForCharge") + count_for_charge: Any balance: CommonCashResponse point: CommonCashResponse app: Any log: Any status: str - allow_duplicates: bool = Field(..., validation_alias="allowDuplicates") - is_refunded: bool = Field(..., validation_alias="isRefunded") - account_id: str = Field(..., validation_alias="accountId") - master_account_id: str = Field(..., validation_alias="masterAccountId") - api_version: str = Field(..., validation_alias="apiVersion") - group_id: str = Field(..., validation_alias="groupId") + allow_duplicates: bool + is_refunded: bool + account_id: str + master_account_id: Optional[str] + api_version: str + group_id: str price: Any - date_created: datetime = Field(..., validation_alias="dateCreated") - date_updated: datetime = Field(..., validation_alias="dateUpdated") - scheduled_date: datetime = Field(..., validation_alias="scheduledDate") - date_sent: datetime = Field(..., validation_alias="dateSent") - date_completed: datetime = Field(..., validation_alias="dateCompleted") + date_created: Optional[datetime] + date_updated: Optional[datetime] + scheduled_date: Optional[datetime] = Field(default=None) + date_sent: Optional[datetime] + date_completed: Optional[datetime] - model_config = ConfigDict(extra="ignore") + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + extra="ignore", + ) diff --git a/solapi/model/response/send_message_response.py b/solapi/model/response/send_message_response.py index 1ce3dc3..ac6f5cf 100644 --- a/solapi/model/response/send_message_response.py +++ b/solapi/model/response/send_message_response.py @@ -1,27 +1,28 @@ from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel from solapi.model.response.common_response import GroupMessageResponse class FailedMessage(BaseModel): to: str - from_: str = Field(..., alias="from") + from_: str = Field(..., serialization_alias="from", validation_alias="from") type: str status_message: str = Field(..., alias="statusMessage") country: str message_id: str = Field(..., alias="messageId") status_code: str = Field(..., alias="statusCode") account_id: str = Field(..., alias="accountId") - custom_fields: Optional[dict[str, str]] = Field( - default=None, alias="customFields" - ) + custom_fields: Optional[dict[str, str]] = Field(default=None) + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) class SendMessageResponse(BaseModel): - failed_message_list: list[FailedMessage] = Field( - ..., alias="failedMessageList" - ) - group_info: GroupMessageResponse = Field(..., alias="groupInfo") - message_list: Optional[list] = Field(default=None, alias="messageList") + failed_message_list: Optional[list[FailedMessage]] = None + group_info: GroupMessageResponse + message_list: Optional[list] = Field(default=None) + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) From e3563a871f16e45de952f09bfb08e5859504e156 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 18 Mar 2025 16:44:35 +0900 Subject: [PATCH 10/60] Add MessageItem model --- solapi/message_service.py | 1 - solapi/model/response/send_message_response.py | 11 ++++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/solapi/message_service.py b/solapi/message_service.py index 8e9f7a4..83b3528 100644 --- a/solapi/message_service.py +++ b/solapi/message_service.py @@ -65,7 +65,6 @@ def send( }, data=request.model_dump(exclude_none=True, by_alias=True), ) - print(response) deserialized_response: SendMessageResponse = SendMessageResponse.model_validate( response ) diff --git a/solapi/model/response/send_message_response.py b/solapi/model/response/send_message_response.py index ac6f5cf..1f4f58b 100644 --- a/solapi/model/response/send_message_response.py +++ b/solapi/model/response/send_message_response.py @@ -6,6 +6,15 @@ from solapi.model.response.common_response import GroupMessageResponse +class MessageItem(BaseModel): + message_id: str + status_code: str + status_message: str + custom_fields: Optional[dict[str, str]] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + +# TODO: Need to resolve FailedMessage from property deserialize issue. class FailedMessage(BaseModel): to: str from_: str = Field(..., serialization_alias="from", validation_alias="from") @@ -23,6 +32,6 @@ class FailedMessage(BaseModel): class SendMessageResponse(BaseModel): failed_message_list: Optional[list[FailedMessage]] = None group_info: GroupMessageResponse - message_list: Optional[list] = Field(default=None) + message_list: Optional[list[MessageItem]] = Field(default=None) model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) From d06bde229485ac04053e435a67ac8fce64a3f7c3 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 18 Mar 2025 22:25:46 +0900 Subject: [PATCH 11/60] Update FailedMessage type. Add MessageNotReceiveError --- solapi/error/MessageNotReceiveError.py | 10 ++++++++++ solapi/message_service.py | 3 ++- solapi/model/response/send_message_response.py | 13 +++++-------- 3 files changed, 17 insertions(+), 9 deletions(-) create mode 100644 solapi/error/MessageNotReceiveError.py diff --git a/solapi/error/MessageNotReceiveError.py b/solapi/error/MessageNotReceiveError.py new file mode 100644 index 0000000..01be676 --- /dev/null +++ b/solapi/error/MessageNotReceiveError.py @@ -0,0 +1,10 @@ +from solapi.model.response.send_message_response import FailedMessage + + +class MessageNotReceivedError(Exception): + def __init__(self, failed_messages: list[FailedMessage]): + self.failed_messages = failed_messages + + def __str__(self): + # TODO: i18n needed + return "메시지 접수에 실패했습니다.\n자세한 내용은 MessageNotReceivedError의 failed_messages 프로퍼티를 참조해주세요." \ No newline at end of file diff --git a/solapi/message_service.py b/solapi/message_service.py index 83b3528..bf451cb 100644 --- a/solapi/message_service.py +++ b/solapi/message_service.py @@ -1,6 +1,7 @@ from datetime import datetime from typing import Optional, Union +from solapi.error.MessageNotReceiveError import MessageNotReceivedError from solapi.lib.authenticator import AuthenticationParameter from solapi.lib.fetcher import RequestMethod, default_fetcher from solapi.lib.string_date_transfer import format_with_transfer @@ -73,6 +74,6 @@ def send( failed_messages = deserialized_response.failed_message_list registered_failed_count = count.registered_failed if len(failed_messages) > 0 and count.total == registered_failed_count: - raise ValueError("an error occurred") + raise MessageNotReceivedError(failed_messages) from ValueError return deserialized_response diff --git a/solapi/model/response/send_message_response.py b/solapi/model/response/send_message_response.py index 1f4f58b..e11fcbc 100644 --- a/solapi/model/response/send_message_response.py +++ b/solapi/model/response/send_message_response.py @@ -14,24 +14,21 @@ class MessageItem(BaseModel): model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) -# TODO: Need to resolve FailedMessage from property deserialize issue. class FailedMessage(BaseModel): - to: str - from_: str = Field(..., serialization_alias="from", validation_alias="from") + to: Optional[str] + from_: Optional[str] = Field(..., serialization_alias="from", validation_alias="from") type: str status_message: str = Field(..., alias="statusMessage") - country: str + country: Optional[str] message_id: str = Field(..., alias="messageId") status_code: str = Field(..., alias="statusCode") account_id: str = Field(..., alias="accountId") - custom_fields: Optional[dict[str, str]] = Field(default=None) - - model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + custom_fields: Optional[dict[str, str]] = Field(default=None, alias="customFields") class SendMessageResponse(BaseModel): failed_message_list: Optional[list[FailedMessage]] = None group_info: GroupMessageResponse - message_list: Optional[list[MessageItem]] = Field(default=None) + message_list: Optional[list[MessageItem]] = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) From b6566613237d8588551b9f5c642bbef181b2524c Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 19 Mar 2025 22:57:05 +0900 Subject: [PATCH 12/60] Add upload_file method --- solapi/error/MessageNotReceiveError.py | 4 +-- solapi/message_service.py | 28 ++++++++++++++++++ solapi/model/message.py | 4 ++- solapi/model/request/storage.py | 18 ++++++++++++ .../model/response/send_message_response.py | 5 +++- solapi/model/response/storage.py | 29 +++++++++++++++++++ 6 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 solapi/model/request/storage.py create mode 100644 solapi/model/response/storage.py diff --git a/solapi/error/MessageNotReceiveError.py b/solapi/error/MessageNotReceiveError.py index 01be676..bd775ab 100644 --- a/solapi/error/MessageNotReceiveError.py +++ b/solapi/error/MessageNotReceiveError.py @@ -4,7 +4,7 @@ class MessageNotReceivedError(Exception): def __init__(self, failed_messages: list[FailedMessage]): self.failed_messages = failed_messages - + def __str__(self): # TODO: i18n needed - return "메시지 접수에 실패했습니다.\n자세한 내용은 MessageNotReceivedError의 failed_messages 프로퍼티를 참조해주세요." \ No newline at end of file + return "메시지 접수에 실패했습니다.\n자세한 내용은 MessageNotReceivedError의 failed_messages 프로퍼티를 참조해 주세요." diff --git a/solapi/message_service.py b/solapi/message_service.py index bf451cb..8f6f421 100644 --- a/solapi/message_service.py +++ b/solapi/message_service.py @@ -1,4 +1,6 @@ +import base64 from datetime import datetime +from pathlib import Path from typing import Optional, Union from solapi.error.MessageNotReceiveError import MessageNotReceivedError @@ -10,7 +12,9 @@ SendMessageRequest, SendRequestConfig, ) +from solapi.model.request.storage import FileTypeEnum, FileUploadRequest from solapi.model.response.send_message_response import SendMessageResponse +from solapi.model.response.storage import FileUploadResponse class SolapiMessageService: @@ -77,3 +81,27 @@ def send( raise MessageNotReceivedError(failed_messages) from ValueError return deserialized_response + + def upload_file( + self, file_path: str, upload_type: FileTypeEnum = FileTypeEnum.MMS + ) -> FileUploadResponse: + path = Path(file_path) + with open(path, "rb") as image_file: + encoded_string = base64.b64encode(image_file.read()) + + request = FileUploadRequest( + file=str(encoded_string)[2:-1], + type=upload_type, + ).model_dump(exclude_none=True) + response = default_fetcher( + self.auth_info, + request={ + "url": f"{self.base_url}/storage/v1/files", + "method": RequestMethod.POST, + }, + data=request, + ) + deserialized_response: FileUploadResponse = FileUploadResponse.model_validate( + response + ) + return deserialized_response diff --git a/solapi/model/message.py b/solapi/model/message.py index a74d15e..3609f77 100644 --- a/solapi/model/message.py +++ b/solapi/model/message.py @@ -100,5 +100,7 @@ class Message(BaseModel): ) model_config = ConfigDict( - extra="ignore", populate_by_name=True, alias_generator=to_camel + extra="ignore", + populate_by_name=True, + alias_generator=to_camel, ) diff --git a/solapi/model/request/storage.py b/solapi/model/request/storage.py new file mode 100644 index 0000000..e46a657 --- /dev/null +++ b/solapi/model/request/storage.py @@ -0,0 +1,18 @@ +from enum import Enum +from typing import Optional + +from pydantic import BaseModel + + +class FileTypeEnum(str, Enum): + MMS = "MMS" + KAKAO = "KAKAO" + RCS = "RCS" + FAX = "FAX" + + +class FileUploadRequest(BaseModel): + type: FileTypeEnum + file: str + name: Optional[str] = None + link: Optional[str] = None diff --git a/solapi/model/response/send_message_response.py b/solapi/model/response/send_message_response.py index e11fcbc..3c57fff 100644 --- a/solapi/model/response/send_message_response.py +++ b/solapi/model/response/send_message_response.py @@ -14,9 +14,12 @@ class MessageItem(BaseModel): model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + class FailedMessage(BaseModel): to: Optional[str] - from_: Optional[str] = Field(..., serialization_alias="from", validation_alias="from") + from_: Optional[str] = Field( + ..., serialization_alias="from", validation_alias="from" + ) type: str status_message: str = Field(..., alias="statusMessage") country: Optional[str] diff --git a/solapi/model/response/storage.py b/solapi/model/response/storage.py new file mode 100644 index 0000000..75fb75d --- /dev/null +++ b/solapi/model/response/storage.py @@ -0,0 +1,29 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class KakaoFileUploadResponseProperty(BaseModel): + daou: Optional[str] = None + biztalk: Optional[str] = None + + +class FileUploadResponse(BaseModel): + kakao: Optional[KakaoFileUploadResponseProperty] = None + type: str + original_name: str + link: Optional[str] = None + file_id: str + name: str + url: str + account_id: str + references: Optional[list[str]] = None + date_created: Optional[datetime] = None + date_updated: Optional[datetime] = None + + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + ) From 85c975d23d166a41b26c36ab267bd1e621b707f6 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 21 Mar 2025 16:27:25 +0900 Subject: [PATCH 13/60] Update kakao_option.py and common_response.py --- solapi/model/kakao/kakao_option.py | 16 +++++++++ solapi/model/response/common_response.py | 43 ++++++++++++++---------- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/solapi/model/kakao/kakao_option.py b/solapi/model/kakao/kakao_option.py index e69de29..ba252df 100644 --- a/solapi/model/kakao/kakao_option.py +++ b/solapi/model/kakao/kakao_option.py @@ -0,0 +1,16 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class KakaoOption(BaseModel): + pf_id: str + template_id: Optional[str] = None + variables: Optional[dict[str, str]] = None + disable_sms: bool = False + ad_flag: bool = False + buttons: Optional[dict[str, str]] = None + image_id: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/response/common_response.py b/solapi/model/response/common_response.py index 78029ea..4d9b1e6 100644 --- a/solapi/model/response/common_response.py +++ b/solapi/model/response/common_response.py @@ -28,28 +28,37 @@ class CommonCashResponse(BaseModel): class CountForChargeResponse(BaseModel): - sms: dict[str, int] - lms: dict[str, int] - mms: dict[str, int] - ata: dict[str, int] - cta: dict[str, int] - cti: dict[str, int] - nsa: dict[str, int] - rcs_sms: dict[str, int] - rcs_lms: dict[str, int] - rcs_mms: dict[str, int] - rcs_tpl: dict[str, int] + sms: dict[str, float] + lms: dict[str, float] + mms: dict[str, float] + ata: dict[str, float] + cta: dict[str, float] + cti: dict[str, float] + nsa: dict[str, float] + rcs_sms: dict[str, float] + rcs_lms: dict[str, float] + rcs_mms: dict[str, float] + rcs_tpl: dict[str, float] model_config = ConfigDict(extra="ignore") +class AppResponse(BaseModel): + profit: CountForChargeResponse + app_id: Optional[str] = None + + model_config = ConfigDict( + extra="ignore", alias_generator=to_camel, populate_by_name=True + ) + + class GroupMessageResponse(BaseModel): count: CountResponse - count_for_charge: Any + count_for_charge: CountForChargeResponse balance: CommonCashResponse point: CommonCashResponse - app: Any - log: Any + app: AppResponse + log: list[dict[str, str]] status: str allow_duplicates: bool is_refunded: bool @@ -60,9 +69,9 @@ class GroupMessageResponse(BaseModel): price: Any date_created: Optional[datetime] date_updated: Optional[datetime] - scheduled_date: Optional[datetime] = Field(default=None) - date_sent: Optional[datetime] - date_completed: Optional[datetime] + scheduled_date: Optional[datetime] = None + date_sent: Optional[datetime] = None + date_completed: Optional[datetime] = None model_config = ConfigDict( alias_generator=to_camel, From b550a79c608085f0880ed968365fc8ae12dc7912 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Sat, 22 Mar 2025 19:34:56 +0900 Subject: [PATCH 14/60] Update models --- solapi/message_service.py | 30 +++++++++++++++++--- solapi/model/rcs/rcs_options.py | 14 +++++++++ solapi/model/request/send_message_request.py | 17 +++++------ solapi/model/response/common_response.py | 6 ++-- 4 files changed, 52 insertions(+), 15 deletions(-) create mode 100644 solapi/model/rcs/rcs_options.py diff --git a/solapi/message_service.py b/solapi/message_service.py index 8f6f421..2d92c86 100644 --- a/solapi/message_service.py +++ b/solapi/message_service.py @@ -53,15 +53,15 @@ def send( request.allow_duplicates = request_config.allow_duplicates request.show_message_list = request_config.show_message_list - if ( - request_config.scheduled_date is not None - and request_config.scheduled_date != "" - and isinstance(request_config.scheduled_date, datetime) + if request_config.scheduled_date is not None and ( + request_config.scheduled_date != "" + or isinstance(request_config.scheduled_date, datetime) ): request.scheduled_date = format_with_transfer( request_config.scheduled_date ) + print(request.model_dump(exclude_none=True, by_alias=True)) response = default_fetcher( self.auth_info, request={ @@ -105,3 +105,25 @@ def upload_file( response ) return deserialized_response + + # TODO: 조회 기능들 개발해야 함 + def get_groups(self): + return "" + + def get_group(self, group_id: str): + return group_id + + def get_group_messages(self, group_id: str): + return group_id + + def get_messages(self): + return "" + + def get_message(self, message_id: str): + return message_id + + def get_naver_templates(self): + return "" + + def get_balance(self): + return "" diff --git a/solapi/model/rcs/rcs_options.py b/solapi/model/rcs/rcs_options.py new file mode 100644 index 0000000..48d94e8 --- /dev/null +++ b/solapi/model/rcs/rcs_options.py @@ -0,0 +1,14 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class RcsOptions(BaseModel): + brand_id: Optional[str] = None + template_id: Optional[str] = None + copy_allowed: Optional[bool] = None + variables: Optional[dict[str, str]] = None + mms_type: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/request/send_message_request.py b/solapi/model/request/send_message_request.py index e414b1f..292b4f8 100644 --- a/solapi/model/request/send_message_request.py +++ b/solapi/model/request/send_message_request.py @@ -2,19 +2,20 @@ from datetime import datetime from typing import Optional, Union -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel from solapi.model.message import Message class SendRequestConfig(BaseModel): - app_id: Optional[str] = Field(default=None, serialization_alias="appId") - allow_duplicates: bool = Field(default=False, serialization_alias="allowDuplicates") - show_message_list: bool = Field( - default=False, serialization_alias="showMessageList" - ) - scheduled_date: Optional[Union[str, datetime]] = Field( - default=None, serialization_alias="scheduledDate" + app_id: Optional[str] = None + allow_duplicates: bool = False + show_message_list: bool = False + scheduled_date: Optional[Union[str, datetime]] = None + + model_config = ConfigDict( + alias_generator=to_camel, extra="ignore", populate_by_name=True ) diff --git a/solapi/model/response/common_response.py b/solapi/model/response/common_response.py index 4d9b1e6..2aa61b4 100644 --- a/solapi/model/response/common_response.py +++ b/solapi/model/response/common_response.py @@ -54,11 +54,11 @@ class AppResponse(BaseModel): class GroupMessageResponse(BaseModel): count: CountResponse - count_for_charge: CountForChargeResponse + count_for_charge: Any balance: CommonCashResponse point: CommonCashResponse - app: AppResponse - log: list[dict[str, str]] + app: Any + log: Any status: str allow_duplicates: bool is_refunded: bool From dabc59e423083f0b9db64b5e6808b3818f2465c1 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Sun, 23 Mar 2025 15:43:15 +0900 Subject: [PATCH 15/60] Update __init__.py to shorten import code --- solapi/__init__.py | 3 +++ solapi/model/__init__.py | 5 +++++ solapi/services/__init__.py | 0 3 files changed, 8 insertions(+) create mode 100644 solapi/services/__init__.py diff --git a/solapi/__init__.py b/solapi/__init__.py index e69de29..604b4d8 100644 --- a/solapi/__init__.py +++ b/solapi/__init__.py @@ -0,0 +1,3 @@ +from .services.message_service import SolapiMessageService + +__all__ = ['SolapiMessageService'] \ No newline at end of file diff --git a/solapi/model/__init__.py b/solapi/model/__init__.py index e69de29..1fe7af1 100644 --- a/solapi/model/__init__.py +++ b/solapi/model/__init__.py @@ -0,0 +1,5 @@ +from .kakao.kakao_option import KakaoOption +from .message import Message +from .request.send_message_request import SendRequestConfig + +__all__ = ['Message', 'SendRequestConfig', 'KakaoOption'] \ No newline at end of file diff --git a/solapi/services/__init__.py b/solapi/services/__init__.py new file mode 100644 index 0000000..e69de29 From 583caa6113fdeeb47b19e65500aee577a2d13751 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Sun, 23 Mar 2025 15:46:35 +0900 Subject: [PATCH 16/60] Format files and remove print code in message_service.py --- examples/sms/sms.py | 4 ++-- solapi/__init__.py | 2 +- solapi/model/__init__.py | 2 +- solapi/model/response/common_response.py | 30 +++++++++++++++++++++--- solapi/{ => services}/message_service.py | 2 -- 5 files changed, 31 insertions(+), 9 deletions(-) rename solapi/{ => services}/message_service.py (98%) diff --git a/examples/sms/sms.py b/examples/sms/sms.py index f0b298d..afd6e6a 100644 --- a/examples/sms/sms.py +++ b/examples/sms/sms.py @@ -1,5 +1,5 @@ -from solapi.message_service import SolapiMessageService -from solapi.model.message import Message +from solapi import SolapiMessageService +from solapi.model import Message message_service = SolapiMessageService( api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" diff --git a/solapi/__init__.py b/solapi/__init__.py index 604b4d8..b21b65c 100644 --- a/solapi/__init__.py +++ b/solapi/__init__.py @@ -1,3 +1,3 @@ from .services.message_service import SolapiMessageService -__all__ = ['SolapiMessageService'] \ No newline at end of file +__all__ = ["SolapiMessageService"] diff --git a/solapi/model/__init__.py b/solapi/model/__init__.py index 1fe7af1..9657cb2 100644 --- a/solapi/model/__init__.py +++ b/solapi/model/__init__.py @@ -2,4 +2,4 @@ from .message import Message from .request.send_message_request import SendRequestConfig -__all__ = ['Message', 'SendRequestConfig', 'KakaoOption'] \ No newline at end of file +__all__ = ["Message", "SendRequestConfig", "KakaoOption"] diff --git a/solapi/model/response/common_response.py b/solapi/model/response/common_response.py index 2aa61b4..3dc5aa7 100644 --- a/solapi/model/response/common_response.py +++ b/solapi/model/response/common_response.py @@ -39,12 +39,36 @@ class CountForChargeResponse(BaseModel): rcs_lms: dict[str, float] rcs_mms: dict[str, float] rcs_tpl: dict[str, float] + rcs_itpl: dict[str, float] + rcs_ltpl: dict[str, float] + fax: dict[str, float] + voice: dict[str, float] + + model_config = ConfigDict(extra="ignore") + + +class AppProfitResponse(BaseModel): + sms: float + lms: float + mms: float + ata: float + cta: float + cti: float + nsa: float + rcs_sms: float + rcs_lms: float + rcs_mms: float + rcs_tpl: float + rcs_itpl: float + rcs_ltpl: float + fax: float + voice: float model_config = ConfigDict(extra="ignore") class AppResponse(BaseModel): - profit: CountForChargeResponse + profit: AppProfitResponse app_id: Optional[str] = None model_config = ConfigDict( @@ -54,10 +78,10 @@ class AppResponse(BaseModel): class GroupMessageResponse(BaseModel): count: CountResponse - count_for_charge: Any + count_for_charge: CountForChargeResponse balance: CommonCashResponse point: CommonCashResponse - app: Any + app: AppResponse log: Any status: str allow_duplicates: bool diff --git a/solapi/message_service.py b/solapi/services/message_service.py similarity index 98% rename from solapi/message_service.py rename to solapi/services/message_service.py index 2d92c86..9ff234c 100644 --- a/solapi/message_service.py +++ b/solapi/services/message_service.py @@ -60,8 +60,6 @@ def send( request.scheduled_date = format_with_transfer( request_config.scheduled_date ) - - print(request.model_dump(exclude_none=True, by_alias=True)) response = default_fetcher( self.auth_info, request={ From 5067f0f31fd9b24e1506a8588aa6059a4d4f59d1 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Sun, 23 Mar 2025 16:24:09 +0900 Subject: [PATCH 17/60] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f60de74..e9a593e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ # Solapi SDK for Python -[![Python 3.13.2](https://img.shields.io/badge/python-3.13.2-blue.svg)](https://www.python.org/downloads) -![Python Supported version](https://img.shields.io/badge/python-%3E%3D3.9-orange) \ No newline at end of file +[![Python 3.9.21](https://img.shields.io/badge/python-3.9.21-blue.svg)](https://www.python.org/downloads) +![Python Supported version](https://img.shields.io/badge/python-%3E%3D3.9-orange) From 73ccf0ceda1810b124ec92c0eaa2ffd04b8cac78 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Sun, 23 Mar 2025 16:24:31 +0900 Subject: [PATCH 18/60] Update send_message_request.py --- solapi/model/request/__init__.py | 2 ++ solapi/model/request/send_message_request.py | 26 +++++++++----------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/solapi/model/request/__init__.py b/solapi/model/request/__init__.py index e69de29..d08287d 100644 --- a/solapi/model/request/__init__.py +++ b/solapi/model/request/__init__.py @@ -0,0 +1,2 @@ +# NOTE: Python SDK가 업데이트 될 때마다 Version도 갱신해야 함! +VERSION = "python/5.0.0" diff --git a/solapi/model/request/send_message_request.py b/solapi/model/request/send_message_request.py index 292b4f8..37bf575 100644 --- a/solapi/model/request/send_message_request.py +++ b/solapi/model/request/send_message_request.py @@ -2,10 +2,11 @@ from datetime import datetime from typing import Optional, Union -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict from pydantic.alias_generators import to_camel from solapi.model.message import Message +from solapi.model.request import VERSION class SendRequestConfig(BaseModel): @@ -14,24 +15,19 @@ class SendRequestConfig(BaseModel): show_message_list: bool = False scheduled_date: Optional[Union[str, datetime]] = None - model_config = ConfigDict( - alias_generator=to_camel, extra="ignore", populate_by_name=True - ) + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) class SendMessageRequest(BaseModel): messages: list[Message] - scheduled_date: Optional[str] = Field( - default=None, serialization_alias="scheduledDate" - ) - show_message_list: Optional[bool] = Field( - default=False, serialization_alias="showMessageList" - ) - allow_duplicates: bool = Field(default=False, serialization_alias="allowDuplicates") - app_id: Optional[str] = Field(default=None, serialization_alias="appId") - - # NOTE: Python SDK 버전 업데이트 할 때마다 변경해줘야 함 + scheduled_date: Optional[str] = None + show_message_list: Optional[bool] = None + allow_duplicates: bool = False + app_id: Optional[str] = None + agent: dict[str, str] = { - "sdkVersion": "python/5.0.0", + "sdkVersion": VERSION, "osPlatform": f"{platform.platform()} | {platform.python_version()}", } + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) From 0fe79f6ddc24cf6f4d3737cc6a42918c4eafdce1 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Sun, 23 Mar 2025 16:24:56 +0900 Subject: [PATCH 19/60] Add kakaoOptions model in Message model --- solapi/model/kakao/kakao_option.py | 1 + solapi/model/message.py | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/solapi/model/kakao/kakao_option.py b/solapi/model/kakao/kakao_option.py index ba252df..04273ba 100644 --- a/solapi/model/kakao/kakao_option.py +++ b/solapi/model/kakao/kakao_option.py @@ -12,5 +12,6 @@ class KakaoOption(BaseModel): ad_flag: bool = False buttons: Optional[dict[str, str]] = None image_id: Optional[str] = None + title: Optional[str] = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/message.py b/solapi/model/message.py index 3609f77..ea1cc6a 100644 --- a/solapi/model/message.py +++ b/solapi/model/message.py @@ -5,6 +5,8 @@ from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel +from solapi.model import KakaoOption + class MessageType(Enum): """ @@ -86,8 +88,7 @@ class Message(BaseModel): serialization_alias="customFields", validation_alias="customFields", ) - # TODO: kakaoOptions Model 정의해야 함.. - kakao_options: Optional[dict[str, Any]] = Field( + kakao_options: Optional[KakaoOption] = Field( default=None, serialization_alias="kakaoOptions", validation_alias="kakaoOptions", From 977245ccdc04b3220f08fbe04c3ba2e0b01a26cd Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Sun, 23 Mar 2025 16:25:16 +0900 Subject: [PATCH 20/60] Fix Response validation error --- solapi/model/response/common_response.py | 28 +++++++++++++----------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/solapi/model/response/common_response.py b/solapi/model/response/common_response.py index 3dc5aa7..dab6142 100644 --- a/solapi/model/response/common_response.py +++ b/solapi/model/response/common_response.py @@ -7,15 +7,17 @@ class CountResponse(BaseModel): total: int - sent_total: int = Field(..., validation_alias="sentTotal") - sent_success: int = Field(..., validation_alias="sentSuccess") - sent_pending: int = Field(..., validation_alias="sentPending") - sent_replacement: int = Field(..., validation_alias="sentReplacement") + sent_total: int + sent_success: int + sent_pending: int + sent_replacement: int refund: int - registered_failed: int = Field(..., validation_alias="registeredFailed") - registered_success: int = Field(..., validation_alias="registeredSuccess") + registered_failed: int + registered_success: int - model_config = ConfigDict(extra="ignore") + model_config = ConfigDict( + extra="ignore", alias_generator=to_camel, populate_by_name=True + ) class CommonCashResponse(BaseModel): @@ -27,7 +29,7 @@ class CommonCashResponse(BaseModel): model_config = ConfigDict(extra="ignore") -class CountForChargeResponse(BaseModel): +class CommonPriceTypeResponse(BaseModel): sms: dict[str, float] lms: dict[str, float] mms: dict[str, float] @@ -47,7 +49,7 @@ class CountForChargeResponse(BaseModel): model_config = ConfigDict(extra="ignore") -class AppProfitResponse(BaseModel): +class EachTypePriceResponse(BaseModel): sms: float lms: float mms: float @@ -68,7 +70,7 @@ class AppProfitResponse(BaseModel): class AppResponse(BaseModel): - profit: AppProfitResponse + profit: EachTypePriceResponse app_id: Optional[str] = None model_config = ConfigDict( @@ -78,11 +80,11 @@ class AppResponse(BaseModel): class GroupMessageResponse(BaseModel): count: CountResponse - count_for_charge: CountForChargeResponse + count_for_charge: CommonPriceTypeResponse balance: CommonCashResponse point: CommonCashResponse app: AppResponse - log: Any + log: list[dict[str, Any]] status: str allow_duplicates: bool is_refunded: bool @@ -90,7 +92,7 @@ class GroupMessageResponse(BaseModel): master_account_id: Optional[str] api_version: str group_id: str - price: Any + price: dict[str, EachTypePriceResponse] date_created: Optional[datetime] date_updated: Optional[datetime] scheduled_date: Optional[datetime] = None From 54edfb88120b2f1d9fc92d3b9b1ada463d666178 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Sun, 23 Mar 2025 22:23:50 +0900 Subject: [PATCH 21/60] Update models --- solapi/model/rcs/__init__.py | 0 solapi/model/response/common_response.py | 2 +- solapi/model/response/send_message_response.py | 2 ++ solapi/model/response/storage.py | 5 +++-- 4 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 solapi/model/rcs/__init__.py diff --git a/solapi/model/rcs/__init__.py b/solapi/model/rcs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/solapi/model/response/common_response.py b/solapi/model/response/common_response.py index dab6142..a99ce29 100644 --- a/solapi/model/response/common_response.py +++ b/solapi/model/response/common_response.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Any, Optional -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict from pydantic.alias_generators import to_camel diff --git a/solapi/model/response/send_message_response.py b/solapi/model/response/send_message_response.py index 3c57fff..61b6fbe 100644 --- a/solapi/model/response/send_message_response.py +++ b/solapi/model/response/send_message_response.py @@ -28,6 +28,8 @@ class FailedMessage(BaseModel): account_id: str = Field(..., alias="accountId") custom_fields: Optional[dict[str, str]] = Field(default=None, alias="customFields") + model_config = ConfigDict(extra="ignore", populate_by_name=True) + class SendMessageResponse(BaseModel): failed_message_list: Optional[list[FailedMessage]] = None diff --git a/solapi/model/response/storage.py b/solapi/model/response/storage.py index 75fb75d..10259e0 100644 --- a/solapi/model/response/storage.py +++ b/solapi/model/response/storage.py @@ -9,6 +9,8 @@ class KakaoFileUploadResponseProperty(BaseModel): daou: Optional[str] = None biztalk: Optional[str] = None + model_config = ConfigDict(extra="ignore") + class FileUploadResponse(BaseModel): kakao: Optional[KakaoFileUploadResponseProperty] = None @@ -24,6 +26,5 @@ class FileUploadResponse(BaseModel): date_updated: Optional[datetime] = None model_config = ConfigDict( - alias_generator=to_camel, - populate_by_name=True, + alias_generator=to_camel, populate_by_name=True, extra="ignore" ) From 9e6fc02a0bed123d35e640ad3b20b8c3db9e72c0 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 24 Mar 2025 09:02:46 +0900 Subject: [PATCH 22/60] Declare rcs_options.py --- solapi/model/rcs/rcs_options.py | 64 ++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/solapi/model/rcs/rcs_options.py b/solapi/model/rcs/rcs_options.py index 48d94e8..7e02e9f 100644 --- a/solapi/model/rcs/rcs_options.py +++ b/solapi/model/rcs/rcs_options.py @@ -1,14 +1,76 @@ +from datetime import datetime +from enum import Enum from typing import Optional from pydantic import BaseModel, ConfigDict from pydantic.alias_generators import to_camel +class RcsMmsType(str, Enum): + """ + 사진 문자 타입. 타입: "M3", "S3", "M4", "S4", "M5", "S5", "M6", "S6" (M: 중간 사이즈. S: 작은 사이즈. 숫자: 사진 개수) + """ + + S3 = "S3" + S4 = "S4" + S5 = "S5" + S6 = "S6" + M3 = "M3" + M4 = "M4" + M5 = "M5" + M6 = "M6" + + +class RcsAdditionalBody(BaseModel): + title: str + description: str + image_id: Optional[str] = None + buttons: Optional[dict[str, str]] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class RcsButtonType(str, Enum): + """ + 'WL'(웹링크), 'ML'(지도[좌표]), 'MQ'(지도[쿼리]), 'MR'(위치공유), + 'CA'(캘린더생성), 'CL'(복사), 'DL'(전화걸기), 'MS'(메시지보내기) + """ + + WL = "WL" + ML = "ML" + MR = "MR" + CA = "CA" + CL = "CL" + DL = "DL" + MS = "MS" + + +class RcsButton(BaseModel): + button_type: RcsButtonType + button_name: str + link: Optional[str] = None + latitude: Optional[str] = None + longitude: Optional[str] = None + label: Optional[str] = None + query: Optional[str] = None + title: Optional[str] = None + start_time: Optional[datetime] = None + end_time: Optional[datetime] = None + text: Optional[str] = None + phone: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + class RcsOptions(BaseModel): brand_id: Optional[str] = None template_id: Optional[str] = None copy_allowed: Optional[bool] = None variables: Optional[dict[str, str]] = None - mms_type: Optional[str] = None + mms_type: Optional[RcsMmsType] = None + commercial_type: Optional[bool] = None + disable_sms: Optional[bool] = False + additional_body: Optional[RcsAdditionalBody] = None + buttons: Optional[RcsButton] = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) From 2b04bc5be69e8e36b38f7a9db6bee3e098e663ce Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 24 Mar 2025 16:29:18 +0900 Subject: [PATCH 23/60] Add get group,message, balance methods --- solapi/model/__init__.py | 3 +- solapi/model/kakao/kakao_option.py | 6 +- solapi/model/message.py | 31 ++++-- solapi/model/rcs/rcs_options.py | 18 ++- solapi/model/request/groups/get_groups.py | 42 +++++++ solapi/model/request/messages/get_messages.py | 41 +++++++ solapi/model/response/balance/get_balance.py | 8 ++ .../response/groups/get_group_messages.py | 17 +++ solapi/model/response/groups/get_groups.py | 15 +++ .../model/response/messages/get_messages.py | 17 +++ solapi/services/message_service.py | 105 ++++++++++++++---- 11 files changed, 263 insertions(+), 40 deletions(-) create mode 100644 solapi/model/request/groups/get_groups.py create mode 100644 solapi/model/request/messages/get_messages.py create mode 100644 solapi/model/response/balance/get_balance.py create mode 100644 solapi/model/response/groups/get_group_messages.py create mode 100644 solapi/model/response/groups/get_groups.py create mode 100644 solapi/model/response/messages/get_messages.py diff --git a/solapi/model/__init__.py b/solapi/model/__init__.py index 9657cb2..3f5ed82 100644 --- a/solapi/model/__init__.py +++ b/solapi/model/__init__.py @@ -1,5 +1,6 @@ from .kakao.kakao_option import KakaoOption from .message import Message +from .request.groups.get_groups import GetGroupsRequest from .request.send_message_request import SendRequestConfig -__all__ = ["Message", "SendRequestConfig", "KakaoOption"] +__all__ = ["Message", "SendRequestConfig", "KakaoOption", "GetGroupsRequest"] diff --git a/solapi/model/kakao/kakao_option.py b/solapi/model/kakao/kakao_option.py index 04273ba..19e089d 100644 --- a/solapi/model/kakao/kakao_option.py +++ b/solapi/model/kakao/kakao_option.py @@ -4,14 +4,12 @@ from pydantic.alias_generators import to_camel +# TODO: Request, Response용 모델을 별도로 분리해야 함 class KakaoOption(BaseModel): - pf_id: str + pf_id: Optional[str] = None template_id: Optional[str] = None variables: Optional[dict[str, str]] = None disable_sms: bool = False - ad_flag: bool = False - buttons: Optional[dict[str, str]] = None image_id: Optional[str] = None - title: Optional[str] = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/message.py b/solapi/model/message.py index ea1cc6a..6fc3d0d 100644 --- a/solapi/model/message.py +++ b/solapi/model/message.py @@ -6,6 +6,7 @@ from pydantic.alias_generators import to_camel from solapi.model import KakaoOption +from solapi.model.rcs.rcs_options import RcsOption class MessageType(Enum): @@ -44,9 +45,15 @@ class MessageType(Enum): FAX = "FAX" VOICE = "VOICE" + def __str__(self) -> str: + return self.value + + def __repr__(self): + return repr(self.value) + class FileIdsType(BaseModel): - file_ids: list[str] + file_ids: Optional[list[str]] = None class Message(BaseModel): @@ -71,18 +78,8 @@ class Message(BaseModel): serialization_alias="autoTypeDetect", validation_alias="autoTypeDetect", ) - date_created: Optional[datetime] = Field( - default=None, serialization_alias="dateCreated", validation_alias="dateCreated" - ) - date_updated: Optional[datetime] = Field( - default=None, serialization_alias="dateUpdated", validation_alias="dateUpdated" - ) subject: Optional[str] = None - log: Optional[list[dict[str, Any]]] = None replacements: Optional[list[dict[str, Any]]] = None - status_code: Optional[str] = Field( - default=None, serialization_alias="statusCode", validation_alias="statusCode" - ) custom_fields: Optional[dict[str, str]] = Field( default=None, serialization_alias="customFields", @@ -93,12 +90,22 @@ class Message(BaseModel): serialization_alias="kakaoOptions", validation_alias="kakaoOptions", ) - rcs_options: Optional[dict[str, Any]] = Field( + rcs_options: Optional[RcsOption] = Field( default=None, serialization_alias="rcsOptions", validation_alias="rcsOptions" ) fax_options: Optional[FileIdsType] = Field( default=None, serialization_alias="faxOptions", validation_alias="faxOptions" ) + date_created: Optional[datetime] = Field( + default=None, serialization_alias="dateCreated", validation_alias="dateCreated" + ) + date_updated: Optional[datetime] = Field( + default=None, serialization_alias="dateUpdated", validation_alias="dateUpdated" + ) + log: Optional[list[dict[str, Any]]] = None + status_code: Optional[str] = Field( + default=None, serialization_alias="statusCode", validation_alias="statusCode" + ) model_config = ConfigDict( extra="ignore", diff --git a/solapi/model/rcs/rcs_options.py b/solapi/model/rcs/rcs_options.py index 7e02e9f..ceb8859 100644 --- a/solapi/model/rcs/rcs_options.py +++ b/solapi/model/rcs/rcs_options.py @@ -20,6 +20,12 @@ class RcsMmsType(str, Enum): M5 = "M5" M6 = "M6" + def __str__(self) -> str: + return self.value + + def __repr__(self): + return repr(self.value) + class RcsAdditionalBody(BaseModel): title: str @@ -44,6 +50,12 @@ class RcsButtonType(str, Enum): DL = "DL" MS = "MS" + def __str__(self) -> str: + return self.value + + def __repr__(self): + return repr(self.value) + class RcsButton(BaseModel): button_type: RcsButtonType @@ -62,7 +74,7 @@ class RcsButton(BaseModel): model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) -class RcsOptions(BaseModel): +class RcsOption(BaseModel): brand_id: Optional[str] = None template_id: Optional[str] = None copy_allowed: Optional[bool] = None @@ -70,7 +82,7 @@ class RcsOptions(BaseModel): mms_type: Optional[RcsMmsType] = None commercial_type: Optional[bool] = None disable_sms: Optional[bool] = False - additional_body: Optional[RcsAdditionalBody] = None - buttons: Optional[RcsButton] = None + additional_body: Optional[list[RcsAdditionalBody]] = None + buttons: Optional[list[RcsButton]] = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/request/groups/get_groups.py b/solapi/model/request/groups/get_groups.py new file mode 100644 index 0000000..7e3eb48 --- /dev/null +++ b/solapi/model/request/groups/get_groups.py @@ -0,0 +1,42 @@ +from datetime import datetime +from enum import Enum +from typing import Optional, Union + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + + +class GetGroupsRequest(BaseModel): + start_key: Optional[str] = None + limit: int = 20 + start_date: Optional[Union[str, datetime]] = None + end_date: Optional[Union[str, datetime]] = None + group_id: Optional[str] = Field(default=None, exclude=True) + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class GetGroupsCrteriaType(str, Enum): + group_id = "groupId" + date_created = "dateCreated" + scheduled_date = "scheduledDate" + + def __str__(self) -> str: + return self.value + + def __repr__(self): + return repr(self.value) + + # TODO: count.total, count.sentTotal 추후에 추가해야 함 + + +class GetGroupsFinalizeRequest(BaseModel): + start_key: Optional[str] = None + limit: int = 20 + start_date: Optional[Union[str, datetime]] = None + end_date: Optional[Union[str, datetime]] = None + criteria: Optional[str] = None + cond: Optional[str] = None + value: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/request/messages/get_messages.py b/solapi/model/request/messages/get_messages.py new file mode 100644 index 0000000..c08ad61 --- /dev/null +++ b/solapi/model/request/messages/get_messages.py @@ -0,0 +1,41 @@ +from datetime import datetime +from typing import Optional, Union + +from pydantic import BaseModel, ConfigDict, Field, computed_field +from pydantic.alias_generators import to_camel + +from solapi.lib.string_date_transfer import format_with_transfer +from solapi.model.message import MessageType + + +class GetMessagesRequest(BaseModel): + start_key: Optional[str] = None + limit: Optional[int] = 20 + message_id: Optional[str] = None + message_ids: Optional[list[str]] = None + group_id: Optional[str] = None + from_: Optional[str] = Field(default=None, serialization_alias="from") + to: Optional[str] = None + type: Optional[MessageType] = None + status_code: Optional[str] = None + date_type: Optional[str] = "CREATED" # or "UPDATED" + start_date: Optional[Union[str, datetime]] = None + end_date: Optional[Union[str, datetime]] = None + + model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) + + @computed_field(alias="startDate") + @property + def formatted_start_date(self) -> Union[str, None]: + if self.start_date is not None: + return format_with_transfer(self.start_date) + else: + return None + + @computed_field(alias="endDate") + @property + def formatted_end_date(self) -> Union[str, None]: + if self.end_date is not None: + return format_with_transfer(self.end_date) + else: + return None diff --git a/solapi/model/response/balance/get_balance.py b/solapi/model/response/balance/get_balance.py new file mode 100644 index 0000000..28cc123 --- /dev/null +++ b/solapi/model/response/balance/get_balance.py @@ -0,0 +1,8 @@ +from typing import Optional + +from pydantic import BaseModel + + +class GetBalanceResponse(BaseModel): + balance: Optional[float] = None + point: Optional[float] = None diff --git a/solapi/model/response/groups/get_group_messages.py b/solapi/model/response/groups/get_group_messages.py new file mode 100644 index 0000000..36a9738 --- /dev/null +++ b/solapi/model/response/groups/get_group_messages.py @@ -0,0 +1,17 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + +from solapi.model import Message + + +class GetGroupMessagesResponse(BaseModel): + start_key: Optional[str] = None + next_key: Optional[str] = None + limit: int + message_list: dict[str, Message] = {} + + model_config = ConfigDict( + alias_generator=to_camel, populate_by_name=True, extra="ignore" + ) diff --git a/solapi/model/response/groups/get_groups.py b/solapi/model/response/groups/get_groups.py new file mode 100644 index 0000000..d7e889b --- /dev/null +++ b/solapi/model/response/groups/get_groups.py @@ -0,0 +1,15 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + +from solapi.model.response.common_response import GroupMessageResponse + + +class GetGroupsResponse(BaseModel): + start_key: Optional[str] = None + limit: Optional[int] = None + next_key: Optional[str] = None + group_list: dict[str, GroupMessageResponse] + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/response/messages/get_messages.py b/solapi/model/response/messages/get_messages.py new file mode 100644 index 0000000..c6cb61b --- /dev/null +++ b/solapi/model/response/messages/get_messages.py @@ -0,0 +1,17 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + +from solapi.model import Message + + +class GetMessagesResponse(BaseModel): + limit: int + message_list: dict[str, Message] + start_key: Optional[str] = None + next_key: Optional[str] = None + + model_config = ConfigDict( + alias_generator=to_camel, populate_by_name=True, extra="ignore" + ) diff --git a/solapi/services/message_service.py b/solapi/services/message_service.py index 9ff234c..dd74810 100644 --- a/solapi/services/message_service.py +++ b/solapi/services/message_service.py @@ -2,17 +2,29 @@ from datetime import datetime from pathlib import Path from typing import Optional, Union +from urllib.parse import urlencode from solapi.error.MessageNotReceiveError import MessageNotReceivedError from solapi.lib.authenticator import AuthenticationParameter from solapi.lib.fetcher import RequestMethod, default_fetcher from solapi.lib.string_date_transfer import format_with_transfer from solapi.model.message import Message +from solapi.model.request.groups.get_groups import ( + GetGroupsCrteriaType, + GetGroupsFinalizeRequest, + GetGroupsRequest, +) +from solapi.model.request.messages.get_messages import GetMessagesRequest from solapi.model.request.send_message_request import ( SendMessageRequest, SendRequestConfig, ) from solapi.model.request.storage import FileTypeEnum, FileUploadRequest +from solapi.model.response.balance.get_balance import GetBalanceResponse +from solapi.model.response.common_response import GroupMessageResponse +from solapi.model.response.groups.get_group_messages import GetGroupMessagesResponse +from solapi.model.response.groups.get_groups import GetGroupsResponse +from solapi.model.response.messages.get_messages import GetMessagesResponse from solapi.model.response.send_message_response import SendMessageResponse from solapi.model.response.storage import FileUploadResponse @@ -75,7 +87,11 @@ def send( count = deserialized_response.group_info.count failed_messages = deserialized_response.failed_message_list registered_failed_count = count.registered_failed - if len(failed_messages) > 0 and count.total == registered_failed_count: + if ( + failed_messages is not None + and len(failed_messages) > 0 + and count.total == registered_failed_count + ): raise MessageNotReceivedError(failed_messages) from ValueError return deserialized_response @@ -99,29 +115,78 @@ def upload_file( }, data=request, ) - deserialized_response: FileUploadResponse = FileUploadResponse.model_validate( - response - ) - return deserialized_response + return FileUploadResponse.model_validate(response) - # TODO: 조회 기능들 개발해야 함 - def get_groups(self): - return "" + def get_groups(self, query: Optional[GetGroupsRequest] = None) -> GetGroupsResponse: + request = GetGroupsFinalizeRequest() + if query is not None: + request = request.model_copy(update=query.model_dump(exclude_unset=True)) + if query.group_id is not None and query.group_id != "": + request.criteria = GetGroupsCrteriaType.group_id + request.cond = "eq" + request.value = query.group_id + + encoded_request = urlencode(request.model_dump(exclude_none=True)) + if encoded_request != "": + encoded_request = "?" + encoded_request + + response = default_fetcher( + self.auth_info, + request={ + "url": f"{self.base_url}/messages/v4/groups{encoded_request}", + "method": RequestMethod.GET, + }, + ) + return GetGroupsResponse.model_validate(response) - def get_group(self, group_id: str): - return group_id + def get_group(self, group_id: str) -> GroupMessageResponse: + response = default_fetcher( + self.auth_info, + request={ + "url": f"{self.base_url}/messages/v4/groups/{group_id}", + "method": RequestMethod.GET, + }, + ) + return GroupMessageResponse.model_validate(response) - def get_group_messages(self, group_id: str): - return group_id + def get_group_messages(self, group_id: str) -> GetGroupMessagesResponse: + response = default_fetcher( + self.auth_info, + request={ + "url": f"{self.base_url}/messages/v4/groups/{group_id}/messages", + "method": RequestMethod.GET, + }, + ) + return GetGroupMessagesResponse.model_validate(response) - def get_messages(self): - return "" + def get_messages( + self, query: Optional[GetMessagesRequest] = None + ) -> GetMessagesResponse: + request = GetMessagesRequest() + if query is not None: + request = request.model_copy(update=query.model_dump(exclude_unset=True)) - def get_message(self, message_id: str): - return message_id + encoded_request = urlencode( + request.model_dump(exclude_none=True, by_alias=True) + ) + if encoded_request != "": + encoded_request = "?" + encoded_request - def get_naver_templates(self): - return "" + response = default_fetcher( + self.auth_info, + request={ + "url": f"{self.base_url}/messages/v4/list-old{encoded_request}", + "method": RequestMethod.GET, + }, + ) + return GetMessagesResponse.model_validate(response) - def get_balance(self): - return "" + def get_balance(self) -> GetBalanceResponse: + response = default_fetcher( + self.auth_info, + request={ + "url": f"{self.base_url}/cash/v1/balance", + "method": RequestMethod.GET, + }, + ) + return GetBalanceResponse.model_validate(response) From af41174b199ad18b9b2b6546c4d8a31c12d36866 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 24 Mar 2025 16:32:10 +0900 Subject: [PATCH 24/60] Update README.md --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index e9a593e..b45479e 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,29 @@ [![Python 3.9.21](https://img.shields.io/badge/python-3.9.21-blue.svg)](https://www.python.org/downloads) ![Python Supported version](https://img.shields.io/badge/python-%3E%3D3.9-orange) + + +You can send text messages(SMS, LMS, MMS), Kakao friendtalk(include notification friendtalk) in Korea using this package. +This package is 100% compatible with SOLAPI family services (CoolSMS, Purplebook, etc.). + +Installing +To use the SDK, simply use npm package manager CLI. Type the following into a terminal window. + +```shell +pip instal solapi +``` + +## Usage + +See [examples repository](https://github.com/solapi/solapi-python/tree/main/examples) + +## Opening Issues + +If you encounter a bug with the SOLAPI SDK for PHP we would like to hear about it. +Search the [existing issues](https://github.com/solapi/solapi-python/issues) and try to make sure your problem doesn’t +already exist before opening a new issue, It’s helpful if you include the version of the SDK you are using. +Please include a stack trace and reduced repro case when appropriate, too. + +## License + +Licensed under the MIT License. \ No newline at end of file From 6ddf62873c6ef60b616f83469a03598f6bab2a91 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 24 Mar 2025 16:33:13 +0900 Subject: [PATCH 25/60] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b45479e..4bd107e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Installing To use the SDK, simply use npm package manager CLI. Type the following into a terminal window. ```shell -pip instal solapi +pip install solapi ``` ## Usage @@ -27,4 +27,4 @@ Please include a stack trace and reduced repro case when appropriate, too. ## License -Licensed under the MIT License. \ No newline at end of file +Licensed under the MIT License. From b3c5edbeac1d3976bd43918a3dedb4c5025008cd Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 24 Mar 2025 16:49:34 +0900 Subject: [PATCH 26/60] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4bd107e..a9c1b6b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ You can send text messages(SMS, LMS, MMS), Kakao friendtalk(include notification This package is 100% compatible with SOLAPI family services (CoolSMS, Purplebook, etc.). Installing -To use the SDK, simply use npm package manager CLI. Type the following into a terminal window. +To use the SDK, simply use pip package manager CLI. Type the following into a terminal window. ```shell pip install solapi @@ -20,7 +20,7 @@ See [examples repository](https://github.com/solapi/solapi-python/tree/main/exam ## Opening Issues -If you encounter a bug with the SOLAPI SDK for PHP we would like to hear about it. +If you encounter a bug with the SOLAPI SDK for Python we would like to hear about it. Search the [existing issues](https://github.com/solapi/solapi-python/issues) and try to make sure your problem doesn’t already exist before opening a new issue, It’s helpful if you include the version of the SDK you are using. Please include a stack trace and reduced repro case when appropriate, too. From 1b8d9db4cf11571d339487c61b8ae09adc4640c1 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 24 Mar 2025 21:38:21 +0900 Subject: [PATCH 27/60] Update examples/sms.py --- examples/sms/sms.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/examples/sms/sms.py b/examples/sms/sms.py index afd6e6a..210b06d 100644 --- a/examples/sms/sms.py +++ b/examples/sms/sms.py @@ -1,5 +1,5 @@ from solapi import SolapiMessageService -from solapi.model import Message +from solapi.model import Message, SendRequestConfig message_service = SolapiMessageService( api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" @@ -7,3 +7,12 @@ message = Message(from_="등록한 발신번호", to="01000000000", text="Hello World!") response = message_service.send(message) print(response) + +# 또는 list Type으로 여러건의 메시지를 발송할 수 있습니다! allow_duplicates를 True로 활성화하면 중복 수신번호를 허용합니다! +messages = [ + Message(from_="등록한 발신번호", to="01000000000", text="Hello World! 1"), + Message(from_="등록한 발신번호", to="01000000000", text="Hello World! 2"), + Message(from_="등록한 발신번호", to="01000000000", text="Hello World! 3"), +] +response = message_service.send(messages, SendRequestConfig(allow_duplicates=True)) +print(response) From 0ab8e4e1e2ed954f2d0556f7b1cc2ad1575a80ca Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 25 Mar 2025 12:50:51 +0900 Subject: [PATCH 28/60] Update pyproject.toml for packaging --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index b6e124d..9d89554 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,3 +38,6 @@ dev = [ [build-system] requires = ["hatchling"] build-backend = "hatchling.build" + +[tool.hatch.build] +packages = ["solapi"] From 92aaba0f1ff28957eea30856e7869066b7e2b601 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 27 Mar 2025 15:18:03 +0900 Subject: [PATCH 29/60] Add a few examples --- examples/balance/get_balance.py | 17 ++++++++++ examples/group/get_groups.py | 23 ++++++++++++++ examples/images/example.jpg | Bin 0 -> 61963 bytes examples/messages/get_messages.py | 18 +++++++++++ examples/simple/send_kakao_alimtalk.py | 36 ++++++++++++++++++++++ examples/simple/send_many.py | 36 ++++++++++++++++++++++ examples/simple/send_mms.py | 41 +++++++++++++++++++++++++ examples/simple/send_sms.py | 24 +++++++++++++++ examples/sms/sms.py | 18 ----------- examples/storage/upload_file.py | 40 ++++++++++++++++++++++++ 10 files changed, 235 insertions(+), 18 deletions(-) create mode 100644 examples/balance/get_balance.py create mode 100644 examples/group/get_groups.py create mode 100644 examples/images/example.jpg create mode 100644 examples/messages/get_messages.py create mode 100644 examples/simple/send_kakao_alimtalk.py create mode 100644 examples/simple/send_many.py create mode 100644 examples/simple/send_mms.py create mode 100644 examples/simple/send_sms.py delete mode 100644 examples/sms/sms.py create mode 100644 examples/storage/upload_file.py diff --git a/examples/balance/get_balance.py b/examples/balance/get_balance.py new file mode 100644 index 0000000..29e8bc1 --- /dev/null +++ b/examples/balance/get_balance.py @@ -0,0 +1,17 @@ +from solapi import SolapiMessageService + +# API 키와 API Secret을 설정합니다 +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +try: + # 잔액을 조회합니다 + balance_response = message_service.get_balance() + + print("잔액 조회 성공!") + print(f"현재 잔액: {balance_response.balance}원") + print(f"포인트: {balance_response.point}P") + +except Exception as e: + print(f"잔액 조회 실패: {str(e)}") diff --git a/examples/group/get_groups.py b/examples/group/get_groups.py new file mode 100644 index 0000000..07099c1 --- /dev/null +++ b/examples/group/get_groups.py @@ -0,0 +1,23 @@ +from solapi import SolapiMessageService + +# API 키와 API Secret을 설정합니다 +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +# 메시지 그룹을 생성하고 조회합니다 +try: + # 그룹 목록을 조회합니다 + groups_response = message_service.get_groups() + print("메시지 그룹 목록:") + for group_id, group in groups_response.group_list.items(): + print("----") + print(f"\nGroup ID: {group_id}") + print(f"생성 시간: {group.date_created}") + print(f"메시지 수: {group.count.total}") + print(f"상태: {group.status}") + print(f"처리 로그: {group.log}") + print("----") + +except Exception as e: + print(f"그룹 조회 실패: {str(e)}") diff --git a/examples/images/example.jpg b/examples/images/example.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d997e8ec3d4cca15bd35fe9b49ee2c164de57af5 GIT binary patch literal 61963 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 Z>XMAtfnCpB%zFz^a{m8}cIwaZ{{tDniP8W7 literal 0 HcmV?d00001 diff --git a/examples/messages/get_messages.py b/examples/messages/get_messages.py new file mode 100644 index 0000000..7f42a06 --- /dev/null +++ b/examples/messages/get_messages.py @@ -0,0 +1,18 @@ +from solapi import SolapiMessageService +# from solapi.model.request.messages.get_messages import GetMessagesRequest + +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +try: + response = message_service.get_messages( + # 메시지를 조회할 때 아래와 같이 조건을 지정할 수 있습니다. + # GetMessagesRequest( + # start_date="2025-01-01", + # end_date="2025-01-03" + # ) + ) + print(response) +except Exception as e: + print(e) diff --git a/examples/simple/send_kakao_alimtalk.py b/examples/simple/send_kakao_alimtalk.py new file mode 100644 index 0000000..d62beff --- /dev/null +++ b/examples/simple/send_kakao_alimtalk.py @@ -0,0 +1,36 @@ +from solapi import SolapiMessageService +from solapi.model import Message +from solapi.model.kakao.kakao_option import KakaoOption + +# API 키와 API Secret을 설정합니다 +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +# 카카오 알림톡 발송을 위한 옵션을 생성합니다. +kakao_option = KakaoOption( + pf_id="계정에 등록된 카카오 비즈니스 채널ID", + template_id="계정에 등록된 카카오 알림톡 템플릿 ID", + # 만약에 템플릿에 변수가 있다면 아래와 같이 설정합니다. + # variables={ + # "#{name}": "홍길동", + # "#{age}": 30 + # } +) + +# 단일 메시지를 생성합니다 +message = Message( + from_="발신번호", # 발신번호 (등록된 발신번호만 사용 가능) + to="수신번호", # 수신번호 + kakao_options=kakao_option, +) + +# 메시지를 발송합니다 +try: + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered}") +except Exception as e: + print(f"메시지 발송 실패: {str(e)}") diff --git a/examples/simple/send_many.py b/examples/simple/send_many.py new file mode 100644 index 0000000..c67d2a8 --- /dev/null +++ b/examples/simple/send_many.py @@ -0,0 +1,36 @@ +from solapi import SolapiMessageService +from solapi.model import Message, SendRequestConfig + +# API 키와 API Secret을 설정합니다 +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +# 여러 메시지를 생성합니다 +messages = [ + Message(from_="발신번호", to="수신번호1", text="첫 번째 메시지입니다."), + Message(from_="발신번호", to="수신번호2", text="두 번째 메시지입니다."), + Message(from_="발신번호", to="수신번호3", text="세 번째 메시지입니다."), +] + +# SendRequestConfig를 사용하여 중복 수신번호 허용 설정 +config = SendRequestConfig( + allow_duplicates=True # 중복 수신번호 허용 +) + +# 메시지를 발송합니다 +try: + response = message_service.send(messages, config) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered}") + + # 실패한 메시지가 있는 경우 + if response.failed_message_list: + print("\n실패한 메시지 목록:") + for failed in response.failed_message_list: + print(f"수신번호: {failed.message.to}") + print(f"실패 사유: {failed.error.message}\n") +except Exception as e: + print(f"메시지 발송 실패: {str(e)}") diff --git a/examples/simple/send_mms.py b/examples/simple/send_mms.py new file mode 100644 index 0000000..0fe7846 --- /dev/null +++ b/examples/simple/send_mms.py @@ -0,0 +1,41 @@ +from os.path import abspath +from solapi import SolapiMessageService +from solapi.model import Message +from solapi.model.request.storage import FileTypeEnum + +# API 키와 API Secret을 설정합니다 +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +# 이미지 파일을 업로드합니다 +try: + # 이미지 파일 업로드 (MMS 타입으로 지정) + file_response = message_service.upload_file( + file_path=abspath( + "../images/example.jpg" + ), # 실제 이미지 파일 경로로 변경해주세요 + upload_type=FileTypeEnum.MMS, + ) + + print("파일 업로드 성공!") + print(f"File ID: {file_response.file_id}") + + # MMS 메시지를 생성하고 발송합니다 + message = Message( + from_="발신번호", # 발신번호 (등록된 발신번호만 사용 가능) + to="수신번호", # 수신번호 + # subject="MMS 제목", # MMS 제목, 제목을 지정하지 않는다면 필요하지 않습니다. + text="MMS 메시지 내용입니다.", + image_id=file_response.file_id, # 업로드된 파일의 ID를 지정 + ) + + # 메시지를 발송합니다 + response = message_service.send(message) + print("\nMMS 발송 성공!") + print(f"Group ID: {response.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered}") + +except Exception as e: + print(f"MMS 발송 실패: {str(e)}") diff --git a/examples/simple/send_sms.py b/examples/simple/send_sms.py new file mode 100644 index 0000000..e412fb1 --- /dev/null +++ b/examples/simple/send_sms.py @@ -0,0 +1,24 @@ +from solapi import SolapiMessageService +from solapi.model import Message + +# API 키와 API Secret을 설정합니다 +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +# 단일 메시지를 생성합니다 +message = Message( + from_="발신번호", # 발신번호 (등록된 발신번호만 사용 가능) + to="수신번호", # 수신번호 + text="안녕하세요! SOLAPI Python SDK를 사용한 SMS 발송 예제입니다.", +) + +# 메시지를 발송합니다 +try: + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered}") +except Exception as e: + print(f"메시지 발송 실패: {str(e)}") diff --git a/examples/sms/sms.py b/examples/sms/sms.py deleted file mode 100644 index 210b06d..0000000 --- a/examples/sms/sms.py +++ /dev/null @@ -1,18 +0,0 @@ -from solapi import SolapiMessageService -from solapi.model import Message, SendRequestConfig - -message_service = SolapiMessageService( - api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" -) -message = Message(from_="등록한 발신번호", to="01000000000", text="Hello World!") -response = message_service.send(message) -print(response) - -# 또는 list Type으로 여러건의 메시지를 발송할 수 있습니다! allow_duplicates를 True로 활성화하면 중복 수신번호를 허용합니다! -messages = [ - Message(from_="등록한 발신번호", to="01000000000", text="Hello World! 1"), - Message(from_="등록한 발신번호", to="01000000000", text="Hello World! 2"), - Message(from_="등록한 발신번호", to="01000000000", text="Hello World! 3"), -] -response = message_service.send(messages, SendRequestConfig(allow_duplicates=True)) -print(response) diff --git a/examples/storage/upload_file.py b/examples/storage/upload_file.py new file mode 100644 index 0000000..ee9bcea --- /dev/null +++ b/examples/storage/upload_file.py @@ -0,0 +1,40 @@ +from solapi import SolapiMessageService +from solapi.model.request.storage import FileTypeEnum + +# API 키와 API Secret을 설정합니다 +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + + +def upload_file_example(file_path: str, file_type: FileTypeEnum): + """ + 파일을 업로드하는 예제 함수입니다. + + Args: + file_path (str): 업로드할 파일의 경로 + file_type (FileTypeEnum): 파일 타입 (MMS, KAKAO, DOCUMENT 등) + """ + try: + # 파일 업로드 + response = message_service.upload_file( + file_path=file_path, upload_type=file_type + ) + + print("파일 업로드 성공!") + print(f"File ID: {response.file_id}") + print(f"File Name: {response.name}") + print(f"File Created: {response.date_created}") + + return response.file_id + + except Exception as e: + print(f"파일 업로드 실패: {str(e)}") + return None + + +# 예제 실행 +if __name__ == "__main__": + # MMS 이미지 업로드 예제 + # 현재 예제는 images 폴더에 있는 example.jpg 파일을 불러오지만, 실제 예제 사용시에는 이미지 파일의 경로를 변경해주세요 + mms_file_id = upload_file_example("../images/example.jpg", FileTypeEnum.MMS) From 09fd78a1268cb4b625dc466d87e98db28d2384a8 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 27 Mar 2025 15:20:34 +0900 Subject: [PATCH 30/60] Update send_kakao_alimtalk.py --- examples/simple/send_kakao_alimtalk.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/simple/send_kakao_alimtalk.py b/examples/simple/send_kakao_alimtalk.py index d62beff..74be4e6 100644 --- a/examples/simple/send_kakao_alimtalk.py +++ b/examples/simple/send_kakao_alimtalk.py @@ -12,9 +12,10 @@ pf_id="계정에 등록된 카카오 비즈니스 채널ID", template_id="계정에 등록된 카카오 알림톡 템플릿 ID", # 만약에 템플릿에 변수가 있다면 아래와 같이 설정합니다. + # 값은 반드시 문자열로 넣어주셔야 합니다! # variables={ # "#{name}": "홍길동", - # "#{age}": 30 + # "#{age}": "30" # } ) From 383668fa274894026567d2ff5b5f9bbe71c50190 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Tue, 1 Apr 2025 10:13:30 +0900 Subject: [PATCH 31/60] Add send_sms_with_reservation.py --- examples/messages/get_messages.py | 2 +- examples/simple/send_mms.py | 5 +-- examples/simple/send_sms.py | 3 +- examples/simple/send_sms_with_reservation.py | 32 ++++++++++++++++++++ 4 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 examples/simple/send_sms_with_reservation.py diff --git a/examples/messages/get_messages.py b/examples/messages/get_messages.py index 7f42a06..0671220 100644 --- a/examples/messages/get_messages.py +++ b/examples/messages/get_messages.py @@ -5,7 +5,7 @@ api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" ) -try: +try: response = message_service.get_messages( # 메시지를 조회할 때 아래와 같이 조건을 지정할 수 있습니다. # GetMessagesRequest( diff --git a/examples/simple/send_mms.py b/examples/simple/send_mms.py index 0fe7846..b36417d 100644 --- a/examples/simple/send_mms.py +++ b/examples/simple/send_mms.py @@ -1,4 +1,5 @@ from os.path import abspath + from solapi import SolapiMessageService from solapi.model import Message from solapi.model.request.storage import FileTypeEnum @@ -33,9 +34,9 @@ # 메시지를 발송합니다 response = message_service.send(message) print("\nMMS 발송 성공!") - print(f"Group ID: {response.group_id}") + print(f"Group ID: {response.group_info.group_id}") print(f"요청한 메시지 개수: {response.group_info.count.total}") - print(f"성공한 메시지 개수: {response.group_info.count.registered}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") except Exception as e: print(f"MMS 발송 실패: {str(e)}") diff --git a/examples/simple/send_sms.py b/examples/simple/send_sms.py index e412fb1..d2aeb28 100644 --- a/examples/simple/send_sms.py +++ b/examples/simple/send_sms.py @@ -19,6 +19,7 @@ print("메시지 발송 성공!") print(f"Group ID: {response.group_info.group_id}") print(f"요청한 메시지 개수: {response.group_info.count.total}") - print(f"성공한 메시지 개수: {response.group_info.count.registered}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") + print(f"실패한 메시지 개수: {response.group_info.count.registered_failed}") except Exception as e: print(f"메시지 발송 실패: {str(e)}") diff --git a/examples/simple/send_sms_with_reservation.py b/examples/simple/send_sms_with_reservation.py new file mode 100644 index 0000000..3e58e64 --- /dev/null +++ b/examples/simple/send_sms_with_reservation.py @@ -0,0 +1,32 @@ +from datetime import datetime + +from solapi import SolapiMessageService +from solapi.model import Message, SendRequestConfig + +# API 키와 API Secret을 설정합니다 +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +# 단일 메시지를 생성합니다 +message = Message( + from_="발신번호", # 발신번호 (등록된 발신번호만 사용 가능) + to="수신번호", # 수신번호 + text="안녕하세요! SOLAPI Python SDK를 사용한 SMS 발송 예제입니다.", +) + +# 예약 발송을 위한 설정을 추가합니다 +request_config = SendRequestConfig(scheduled_date=datetime(2025, 4, 2, 13, 0, 0)) +# 혹은.. +# request_config = SendRequestConfig(scheduled_date="2025-04-02 13:00:00") + +# 메시지를 발송합니다 +try: + response = message_service.send(message, request_config) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") + print(f"실패한 메시지 개수: {response.group_info.count.registered_failed}") +except Exception as e: + print(f"메시지 발송 실패: {str(e)}") From 98b70b84e4d1a16633ea9fa1d8e5ab54b320b51b Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 18 Apr 2025 20:23:39 +0900 Subject: [PATCH 32/60] Add docstring --- solapi/services/message_service.py | 112 +++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/solapi/services/message_service.py b/solapi/services/message_service.py index dd74810..72c5ee7 100644 --- a/solapi/services/message_service.py +++ b/solapi/services/message_service.py @@ -30,7 +30,20 @@ class SolapiMessageService: + """Solapi Message Service for handling message-related operations. + + This class provides methods to interact with the Solapi API for sending various types of messages + (SMS, LMS, MMS, 알림톡, 친구톡, RCS, etc.), managing message groups, uploading files, + and checking account balance. + """ + def __init__(self, api_key: str, api_secret: str): + """Initialize the Solapi Message Service. + + Args: + api_key: The API key for authentication. + api_secret: The API secret for authentication. + """ self.base_url = "https://api.solapi.com" self.auth_info: AuthenticationParameter = { "api_key": api_key, @@ -42,6 +55,28 @@ def send( messages: Union[list[Message], Message], request_config: Optional[SendRequestConfig] = None, ) -> SendMessageResponse: + """Send one or more messages using the Solapi API. + + This method sends various types of messages (SMS, LMS, MMS, 알림톡, 친구톡, RCS, etc.) to recipients. + It supports sending a single message or multiple messages in one request (대량 발송). + + Args: + messages: A single Message object or a list of Message objects to send. + Each Message object contains recipient (수신번호), sender (발신번호), content (내용), + and other message details. + request_config: Optional configuration for the send request. + Can include app_id, allow_duplicates (중복 수신번호 허용), + scheduled_date (예약 발송), and other settings. + + Returns: + SendMessageResponse: The response from the API containing message status, group info, + and other details about the sent messages. + + Raises: + TypeError: If messages parameter is not a Message object or list of Message objects. + ValueError: If no valid messages are provided. + MessageNotReceivedError: If all messages failed to be registered. + """ payload = [] if isinstance(messages, Message): payload.append(messages) @@ -99,6 +134,22 @@ def send( def upload_file( self, file_path: str, upload_type: FileTypeEnum = FileTypeEnum.MMS ) -> FileUploadResponse: + """Upload a file to the Solapi storage service. + + This method uploads a file (such as an image) to be used in MMS (사진 문자) or other message types. + The file is encoded in base64 before being sent to the API. + + Args: + file_path: The path to the file to be uploaded (파일 경로). + upload_type: The type of file being uploaded, defaults to MMS (사진 문자용). + Other possible values are defined in FileTypeEnum. + + Returns: + FileUploadResponse: The response from the API containing the file ID (파일 ID) and other details. + + Raises: + FileNotFoundError: If the specified file path does not exist. + """ path = Path(file_path) with open(path, "rb") as image_file: encoded_string = base64.b64encode(image_file.read()) @@ -118,6 +169,20 @@ def upload_file( return FileUploadResponse.model_validate(response) def get_groups(self, query: Optional[GetGroupsRequest] = None) -> GetGroupsResponse: + """Retrieve message groups (메시지 그룹) from the Solapi API. + + This method fetches message groups based on the provided query parameters. + If no query is provided, it returns all message groups. + 메시지 그룹은 대량 발송 시 생성되는 메시지들의 묶음입니다. + + Args: + query: Optional query parameters to filter the groups. + Can include group_id (그룹 ID), criteria, cond, value, and other filters. + + Returns: + GetGroupsResponse: The response from the API containing the list of message groups + and other metadata. + """ request = GetGroupsFinalizeRequest() if query is not None: request = request.model_copy(update=query.model_dump(exclude_unset=True)) @@ -140,6 +205,17 @@ def get_groups(self, query: Optional[GetGroupsRequest] = None) -> GetGroupsRespo return GetGroupsResponse.model_validate(response) def get_group(self, group_id: str) -> GroupMessageResponse: + """Retrieve a specific message group (메시지 그룹) by its ID. + + This method fetches detailed information about a single message group. + 특정 그룹 ID로 메시지 그룹 정보를 조회합니다. + + Args: + group_id: The unique identifier (그룹 ID) of the message group to retrieve. + + Returns: + GroupMessageResponse: The response from the API containing the message group details. + """ response = default_fetcher( self.auth_info, request={ @@ -150,6 +226,18 @@ def get_group(self, group_id: str) -> GroupMessageResponse: return GroupMessageResponse.model_validate(response) def get_group_messages(self, group_id: str) -> GetGroupMessagesResponse: + """Retrieve all messages within a specific group (그룹 내 메시지 목록 조회). + + This method fetches all messages that belong to the specified message group. + 특정 메시지 그룹에 속한 모든 메시지를 조회합니다. + + Args: + group_id: The unique identifier (그룹 ID) of the message group whose messages should be retrieved. + + Returns: + GetGroupMessagesResponse: The response from the API containing the list of messages + in the group and other metadata. + """ response = default_fetcher( self.auth_info, request={ @@ -162,6 +250,21 @@ def get_group_messages(self, group_id: str) -> GetGroupMessagesResponse: def get_messages( self, query: Optional[GetMessagesRequest] = None ) -> GetMessagesResponse: + """Retrieve messages based on query parameters (메시지 목록 조회). + + This method fetches messages that match the specified query parameters. + If no query is provided, it returns messages based on default parameters. + 조건에 맞는 메시지 목록을 조회합니다. + + Args: + query: Optional query parameters to filter the messages. + Can include criteria for message status (상태), date range (기간), + recipient (수신번호), sender (발신번호), etc. + + Returns: + GetMessagesResponse: The response from the API containing the list of messages + and other metadata. + """ request = GetMessagesRequest() if query is not None: request = request.model_copy(update=query.model_dump(exclude_unset=True)) @@ -182,6 +285,15 @@ def get_messages( return GetMessagesResponse.model_validate(response) def get_balance(self) -> GetBalanceResponse: + """Retrieve the account balance information (잔액 조회). + + This method fetches the current balance and point information for the account. + 계정의 현재 잔액과 포인트 정보를 조회합니다. + + Returns: + GetBalanceResponse: The response from the API containing the account balance details + including cash balance (잔액) and point balance (포인트). + """ response = default_fetcher( self.auth_info, request={ From c6754639cdbef0e0c0ebfc24e17263764c4d8c04 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 18 Apr 2025 20:36:11 +0900 Subject: [PATCH 33/60] Update message model: * Update to model type str -> Union[str, list[str]] --- solapi/model/message.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/solapi/model/message.py b/solapi/model/message.py index 6fc3d0d..ebb9a11 100644 --- a/solapi/model/message.py +++ b/solapi/model/message.py @@ -1,6 +1,6 @@ from datetime import datetime from enum import Enum -from typing import Any, Optional +from typing import Any, Optional, Union from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel @@ -60,7 +60,7 @@ class Message(BaseModel): from_: Optional[str] = Field( default=None, serialization_alias="from", validation_alias="from" ) - to: str + to: Union[str, list[str]] text: Optional[str] = None image_id: Optional[str] = Field( default=None, serialization_alias="imageId", validation_alias="imageId" From e2736ea103cf01892307129fa3145320eedc6e99 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 18 Apr 2025 20:36:25 +0900 Subject: [PATCH 34/60] Fix ruff lint error --- examples/messages/get_messages.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/messages/get_messages.py b/examples/messages/get_messages.py index 0671220..b8ce3ce 100644 --- a/examples/messages/get_messages.py +++ b/examples/messages/get_messages.py @@ -1,4 +1,5 @@ from solapi import SolapiMessageService + # from solapi.model.request.messages.get_messages import GetMessagesRequest message_service = SolapiMessageService( From b5091e791d543954d91b7b39df962a364f06e414 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 7 May 2025 16:46:41 +0900 Subject: [PATCH 35/60] Update dep and reformat files --- pyproject.toml | 7 +- solapi/model/__init__.py | 13 +- solapi/model/kakao/kakao_button.py | 16 + solapi/model/message_type.py | 44 +++ solapi/model/request/messages/get_messages.py | 2 +- uv.lock | 305 ++++++++++++------ 6 files changed, 288 insertions(+), 99 deletions(-) create mode 100644 solapi/model/kakao/kakao_button.py create mode 100644 solapi/model/message_type.py diff --git a/pyproject.toml b/pyproject.toml index 9d89554..524985e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "solapi" -version = "5.0.0" +version = "5.0.1" description = "SOLAPI SDK for Python" authors = [ { name = "SOLAPI Team", email = "contact@solapi.com" } @@ -27,12 +27,13 @@ readme = "README.md" requires-python = ">=3.9" dependencies = [ "httpx (>=0.28.1,<0.29.0)", - "pydantic (>=2.10.6,<3.0.0)" + "pydantic (>=2.11.4,<3.0.0)" ] [project.optional-dependencies] dev = [ - "ruff>=0.11.0" + "ruff>=0.11.0", + "pytest>=7.0.0" ] [build-system] diff --git a/solapi/model/__init__.py b/solapi/model/__init__.py index 3f5ed82..91e1f0a 100644 --- a/solapi/model/__init__.py +++ b/solapi/model/__init__.py @@ -1,6 +1,15 @@ from .kakao.kakao_option import KakaoOption -from .message import Message +from .message_type import MessageType from .request.groups.get_groups import GetGroupsRequest +from .request.message import Message as RequestMessage from .request.send_message_request import SendRequestConfig +from .response.message import Message as ResponseMessage -__all__ = ["Message", "SendRequestConfig", "KakaoOption", "GetGroupsRequest"] +__all__ = [ + "RequestMessage", + "ResponseMessage", + "SendRequestConfig", + "KakaoOption", + "GetGroupsRequest", + "MessageType", +] diff --git a/solapi/model/kakao/kakao_button.py b/solapi/model/kakao/kakao_button.py new file mode 100644 index 0000000..820db5d --- /dev/null +++ b/solapi/model/kakao/kakao_button.py @@ -0,0 +1,16 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class KakaoButton(BaseModel): + link_mo: Optional[str] = None + link_pc: Optional[str] = None + button_name: Optional[str] = None + # TODO: 추후 타입 추가 필요 + button_type: Optional[str] = None + link_and: Optional[str] = None + link_ios: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/message_type.py b/solapi/model/message_type.py new file mode 100644 index 0000000..cfa8b4c --- /dev/null +++ b/solapi/model/message_type.py @@ -0,0 +1,44 @@ +from enum import Enum + + +class MessageType(Enum): + """ + 메시지 유형(단문 문자, 장문 문자, 알림톡 등) + SMS: 단문 문자 + LMS: 장문 문자 + MMS: 사진 문자 + ATA: 알림톡 + CTA: 친구톡 + CTI: 사진 한장이 포함된 친구톡 + NSA: 네이버 스마트알림(톡톡) + RCS_SMS: RCS 단문 문자 + RCS_LMS: RCS 장문 문자 + RCS_MMS: RCS 사진 문자 + RCS_TPL: RCS 템플릿 + RCS_ITPL: RCS 이미지 템플릿 + RCS_LTPL: RCS LMS 템플릿 문자 + FAX: 팩스 + VOICE: 음성문자(TTS) + """ + + SMS = "SMS" + LMS = "LMS" + MMS = "MMS" + ATA = "ATA" + CTA = "CTA" + CTI = "CTI" + NSA = "NSA" + RCS_SMS = "RCS_SMS" + RCS_LMS = "RCS_LMS" + RCS_MMS = "RCS_MMS" + RCS_TPL = "RCS_TPL" + RCS_ITPL = "RCS_ITPL" + RCS_LTPL = "RCS_LTPL" + FAX = "FAX" + VOICE = "VOICE" + + def __str__(self) -> str: + return self.value + + def __repr__(self): + return repr(self.value) diff --git a/solapi/model/request/messages/get_messages.py b/solapi/model/request/messages/get_messages.py index c08ad61..97bae1b 100644 --- a/solapi/model/request/messages/get_messages.py +++ b/solapi/model/request/messages/get_messages.py @@ -5,7 +5,7 @@ from pydantic.alias_generators import to_camel from solapi.lib.string_date_transfer import format_with_transfer -from solapi.model.message import MessageType +from solapi.model.message_type import MessageType class GetMessagesRequest(BaseModel): diff --git a/uv.lock b/uv.lock index eda30f5..ba3ca9a 100644 --- a/uv.lock +++ b/uv.lock @@ -35,6 +35,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + [[package]] name = "exceptiongroup" version = "1.2.2" @@ -90,115 +99,172 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, ] +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + [[package]] name = "pydantic" -version = "2.10.6" +version = "2.11.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 } +sdist = { url = "https://files.pythonhosted.org/packages/77/ab/5250d56ad03884ab5efd07f734203943c8a8ab40d551e208af81d0257bf2/pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d", size = 786540 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 }, + { url = "https://files.pythonhosted.org/packages/e7/12/46b65f3534d099349e38ef6ec98b1a5a81f42536d17e0ba382c28c67ba67/pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb", size = 443900 }, ] [[package]] name = "pydantic-core" -version = "2.27.2" +version = "2.33.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195 } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938 }, - { url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684 }, - { url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169 }, - { url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227 }, - { url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695 }, - { url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662 }, - { url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370 }, - { url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813 }, - { url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287 }, - { url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414 }, - { url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301 }, - { url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685 }, - { url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876 }, - { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421 }, - { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998 }, - { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167 }, - { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071 }, - { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244 }, - { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470 }, - { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291 }, - { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613 }, - { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355 }, - { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661 }, - { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261 }, - { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361 }, - { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484 }, - { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102 }, - { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, - { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, - { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, - { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, - { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, - { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, - { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, - { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, - { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, - { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, - { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, - { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, - { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, - { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, - { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, - { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, - { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, - { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, - { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, - { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, - { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, - { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, - { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, - { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, - { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, - { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, - { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, - { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, - { url = "https://files.pythonhosted.org/packages/27/97/3aef1ddb65c5ccd6eda9050036c956ff6ecbfe66cb7eb40f280f121a5bb0/pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993", size = 1896475 }, - { url = "https://files.pythonhosted.org/packages/ad/d3/5668da70e373c9904ed2f372cb52c0b996426f302e0dee2e65634c92007d/pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308", size = 1772279 }, - { url = "https://files.pythonhosted.org/packages/8a/9e/e44b8cb0edf04a2f0a1f6425a65ee089c1d6f9c4c2dcab0209127b6fdfc2/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4", size = 1829112 }, - { url = "https://files.pythonhosted.org/packages/1c/90/1160d7ac700102effe11616e8119e268770f2a2aa5afb935f3ee6832987d/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf", size = 1866780 }, - { url = "https://files.pythonhosted.org/packages/ee/33/13983426df09a36d22c15980008f8d9c77674fc319351813b5a2739b70f3/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76", size = 2037943 }, - { url = "https://files.pythonhosted.org/packages/01/d7/ced164e376f6747e9158c89988c293cd524ab8d215ae4e185e9929655d5c/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118", size = 2740492 }, - { url = "https://files.pythonhosted.org/packages/8b/1f/3dc6e769d5b7461040778816aab2b00422427bcaa4b56cc89e9c653b2605/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630", size = 1995714 }, - { url = "https://files.pythonhosted.org/packages/07/d7/a0bd09bc39283530b3f7c27033a814ef254ba3bd0b5cfd040b7abf1fe5da/pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54", size = 1997163 }, - { url = "https://files.pythonhosted.org/packages/2d/bb/2db4ad1762e1c5699d9b857eeb41959191980de6feb054e70f93085e1bcd/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f", size = 2005217 }, - { url = "https://files.pythonhosted.org/packages/53/5f/23a5a3e7b8403f8dd8fc8a6f8b49f6b55c7d715b77dcf1f8ae919eeb5628/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362", size = 2127899 }, - { url = "https://files.pythonhosted.org/packages/c2/ae/aa38bb8dd3d89c2f1d8362dd890ee8f3b967330821d03bbe08fa01ce3766/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96", size = 2155726 }, - { url = "https://files.pythonhosted.org/packages/98/61/4f784608cc9e98f70839187117ce840480f768fed5d386f924074bf6213c/pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e", size = 1817219 }, - { url = "https://files.pythonhosted.org/packages/57/82/bb16a68e4a1a858bb3768c2c8f1ff8d8978014e16598f001ea29a25bf1d1/pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67", size = 1985382 }, - { url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159 }, - { url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331 }, - { url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467 }, - { url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797 }, - { url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839 }, - { url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861 }, - { url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582 }, - { url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985 }, - { url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715 }, - { url = "https://files.pythonhosted.org/packages/29/0e/dcaea00c9dbd0348b723cae82b0e0c122e0fa2b43fa933e1622fd237a3ee/pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656", size = 1891733 }, - { url = "https://files.pythonhosted.org/packages/86/d3/e797bba8860ce650272bda6383a9d8cad1d1c9a75a640c9d0e848076f85e/pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278", size = 1768375 }, - { url = "https://files.pythonhosted.org/packages/41/f7/f847b15fb14978ca2b30262548f5fc4872b2724e90f116393eb69008299d/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb", size = 1822307 }, - { url = "https://files.pythonhosted.org/packages/9c/63/ed80ec8255b587b2f108e514dc03eed1546cd00f0af281e699797f373f38/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd", size = 1979971 }, - { url = "https://files.pythonhosted.org/packages/a9/6d/6d18308a45454a0de0e975d70171cadaf454bc7a0bf86b9c7688e313f0bb/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc", size = 1987616 }, - { url = "https://files.pythonhosted.org/packages/82/8a/05f8780f2c1081b800a7ca54c1971e291c2d07d1a50fb23c7e4aef4ed403/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b", size = 1998943 }, - { url = "https://files.pythonhosted.org/packages/5e/3e/fe5b6613d9e4c0038434396b46c5303f5ade871166900b357ada4766c5b7/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b", size = 2116654 }, - { url = "https://files.pythonhosted.org/packages/db/ad/28869f58938fad8cc84739c4e592989730bfb69b7c90a8fff138dff18e1e/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2", size = 2152292 }, - { url = "https://files.pythonhosted.org/packages/a1/0c/c5c5cd3689c32ed1fe8c5d234b079c12c281c051759770c05b8bed6412b5/pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35", size = 2004961 }, + { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817 }, + { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357 }, + { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011 }, + { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730 }, + { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178 }, + { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462 }, + { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652 }, + { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306 }, + { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720 }, + { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915 }, + { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884 }, + { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496 }, + { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019 }, + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584 }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071 }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823 }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792 }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338 }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998 }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200 }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890 }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359 }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883 }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074 }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538 }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909 }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786 }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000 }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996 }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957 }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199 }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296 }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109 }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028 }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044 }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881 }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034 }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187 }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628 }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866 }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894 }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688 }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808 }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580 }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859 }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810 }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498 }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611 }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924 }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196 }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389 }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223 }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473 }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269 }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921 }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162 }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560 }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777 }, + { url = "https://files.pythonhosted.org/packages/53/ea/bbe9095cdd771987d13c82d104a9c8559ae9aec1e29f139e286fd2e9256e/pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d", size = 2028677 }, + { url = "https://files.pythonhosted.org/packages/49/1d/4ac5ed228078737d457a609013e8f7edc64adc37b91d619ea965758369e5/pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954", size = 1864735 }, + { url = "https://files.pythonhosted.org/packages/23/9a/2e70d6388d7cda488ae38f57bc2f7b03ee442fbcf0d75d848304ac7e405b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb", size = 1898467 }, + { url = "https://files.pythonhosted.org/packages/ff/2e/1568934feb43370c1ffb78a77f0baaa5a8b6897513e7a91051af707ffdc4/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7", size = 1983041 }, + { url = "https://files.pythonhosted.org/packages/01/1a/1a1118f38ab64eac2f6269eb8c120ab915be30e387bb561e3af904b12499/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4", size = 2136503 }, + { url = "https://files.pythonhosted.org/packages/5c/da/44754d1d7ae0f22d6d3ce6c6b1486fc07ac2c524ed8f6eca636e2e1ee49b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b", size = 2736079 }, + { url = "https://files.pythonhosted.org/packages/4d/98/f43cd89172220ec5aa86654967b22d862146bc4d736b1350b4c41e7c9c03/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3", size = 2006508 }, + { url = "https://files.pythonhosted.org/packages/2b/cc/f77e8e242171d2158309f830f7d5d07e0531b756106f36bc18712dc439df/pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a", size = 2113693 }, + { url = "https://files.pythonhosted.org/packages/54/7a/7be6a7bd43e0a47c147ba7fbf124fe8aaf1200bc587da925509641113b2d/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782", size = 2074224 }, + { url = "https://files.pythonhosted.org/packages/2a/07/31cf8fadffbb03be1cb520850e00a8490c0927ec456e8293cafda0726184/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9", size = 2245403 }, + { url = "https://files.pythonhosted.org/packages/b6/8d/bbaf4c6721b668d44f01861f297eb01c9b35f612f6b8e14173cb204e6240/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e", size = 2242331 }, + { url = "https://files.pythonhosted.org/packages/bb/93/3cc157026bca8f5006250e74515119fcaa6d6858aceee8f67ab6dc548c16/pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9", size = 1910571 }, + { url = "https://files.pythonhosted.org/packages/5b/90/7edc3b2a0d9f0dda8806c04e511a67b0b7a41d2187e2003673a996fb4310/pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3", size = 1956504 }, + { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982 }, + { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412 }, + { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749 }, + { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527 }, + { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225 }, + { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490 }, + { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525 }, + { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446 }, + { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678 }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200 }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123 }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852 }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484 }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896 }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475 }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013 }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715 }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757 }, + { url = "https://files.pythonhosted.org/packages/08/98/dbf3fdfabaf81cda5622154fda78ea9965ac467e3239078e0dcd6df159e7/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101", size = 2024034 }, + { url = "https://files.pythonhosted.org/packages/8d/99/7810aa9256e7f2ccd492590f86b79d370df1e9292f1f80b000b6a75bd2fb/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64", size = 1858578 }, + { url = "https://files.pythonhosted.org/packages/d8/60/bc06fa9027c7006cc6dd21e48dbf39076dc39d9abbaf718a1604973a9670/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d", size = 1892858 }, + { url = "https://files.pythonhosted.org/packages/f2/40/9d03997d9518816c68b4dfccb88969756b9146031b61cd37f781c74c9b6a/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535", size = 2068498 }, + { url = "https://files.pythonhosted.org/packages/d8/62/d490198d05d2d86672dc269f52579cad7261ced64c2df213d5c16e0aecb1/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d", size = 2108428 }, + { url = "https://files.pythonhosted.org/packages/9a/ec/4cd215534fd10b8549015f12ea650a1a973da20ce46430b68fc3185573e8/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6", size = 2069854 }, + { url = "https://files.pythonhosted.org/packages/1a/1a/abbd63d47e1d9b0d632fee6bb15785d0889c8a6e0a6c3b5a8e28ac1ec5d2/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca", size = 2237859 }, + { url = "https://files.pythonhosted.org/packages/80/1c/fa883643429908b1c90598fd2642af8839efd1d835b65af1f75fba4d94fe/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039", size = 2239059 }, + { url = "https://files.pythonhosted.org/packages/d4/29/3cade8a924a61f60ccfa10842f75eb12787e1440e2b8660ceffeb26685e7/pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27", size = 2066661 }, +] + +[[package]] +name = "pytest" +version = "8.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, ] [[package]] @@ -237,7 +303,7 @@ wheels = [ [[package]] name = "solapi" -version = "5.0.0" +version = "5.0.1" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -246,17 +312,58 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "pytest" }, { name = "ruff" }, ] [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.28.1,<0.29.0" }, - { name = "pydantic", specifier = ">=2.10.6,<3.0.0" }, + { name = "pydantic", specifier = ">=2.11.4,<3.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11.0" }, ] provides-extras = ["dev"] +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +] + [[package]] name = "typing-extensions" version = "4.12.2" @@ -265,3 +372,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec3 wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, ] + +[[package]] +name = "typing-inspection" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 }, +] From 4cf68b52201c8b535565225c74b4c5d1997368c0 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Wed, 7 May 2025 21:02:30 +0900 Subject: [PATCH 36/60] Update message models and Add pytest code --- examples/simple/send_kakao_alimtalk.py | 4 +- examples/simple/send_many.py | 8 +- examples/simple/send_mms.py | 4 +- examples/simple/send_sms.py | 4 +- examples/simple/send_sms_with_reservation.py | 4 +- solapi/model/kakao/kakao_option.py | 11 +- solapi/model/rcs/rcs_options.py | 5 +- solapi/model/request/kakao/kakao_option.py | 17 ++ solapi/model/request/message.py | 60 ++++ solapi/model/request/send_message_request.py | 2 +- .../response/groups/get_group_messages.py | 2 +- solapi/model/{ => response}/message.py | 45 +-- .../model/response/messages/get_messages.py | 2 +- solapi/services/message_service.py | 22 +- tests/README.md | 64 ++++ tests/conftest.py | 65 ++++ tests/test_balance.py | 33 ++ tests/test_group.py | 106 +++++++ tests/test_messages.py | 86 ++++++ tests/test_simple_send.py | 281 ++++++++++++++++++ tests/test_storage.py | 130 ++++++++ 21 files changed, 881 insertions(+), 74 deletions(-) create mode 100644 solapi/model/request/kakao/kakao_option.py create mode 100644 solapi/model/request/message.py rename solapi/model/{ => response}/message.py (71%) create mode 100644 tests/README.md create mode 100644 tests/conftest.py create mode 100644 tests/test_balance.py create mode 100644 tests/test_group.py create mode 100644 tests/test_messages.py create mode 100644 tests/test_simple_send.py create mode 100644 tests/test_storage.py diff --git a/examples/simple/send_kakao_alimtalk.py b/examples/simple/send_kakao_alimtalk.py index 74be4e6..ffd59e1 100644 --- a/examples/simple/send_kakao_alimtalk.py +++ b/examples/simple/send_kakao_alimtalk.py @@ -1,5 +1,5 @@ from solapi import SolapiMessageService -from solapi.model import Message +from solapi.model import RequestMessage from solapi.model.kakao.kakao_option import KakaoOption # API 키와 API Secret을 설정합니다 @@ -20,7 +20,7 @@ ) # 단일 메시지를 생성합니다 -message = Message( +message = RequestMessage( from_="발신번호", # 발신번호 (등록된 발신번호만 사용 가능) to="수신번호", # 수신번호 kakao_options=kakao_option, diff --git a/examples/simple/send_many.py b/examples/simple/send_many.py index c67d2a8..d619387 100644 --- a/examples/simple/send_many.py +++ b/examples/simple/send_many.py @@ -1,5 +1,5 @@ from solapi import SolapiMessageService -from solapi.model import Message, SendRequestConfig +from solapi.model import RequestMessage, SendRequestConfig # API 키와 API Secret을 설정합니다 message_service = SolapiMessageService( @@ -8,9 +8,9 @@ # 여러 메시지를 생성합니다 messages = [ - Message(from_="발신번호", to="수신번호1", text="첫 번째 메시지입니다."), - Message(from_="발신번호", to="수신번호2", text="두 번째 메시지입니다."), - Message(from_="발신번호", to="수신번호3", text="세 번째 메시지입니다."), + RequestMessage(from_="발신번호", to="수신번호1", text="첫 번째 메시지입니다."), + RequestMessage(from_="발신번호", to="수신번호2", text="두 번째 메시지입니다."), + RequestMessage(from_="발신번호", to="수신번호3", text="세 번째 메시지입니다."), ] # SendRequestConfig를 사용하여 중복 수신번호 허용 설정 diff --git a/examples/simple/send_mms.py b/examples/simple/send_mms.py index b36417d..ec2973b 100644 --- a/examples/simple/send_mms.py +++ b/examples/simple/send_mms.py @@ -1,7 +1,7 @@ from os.path import abspath from solapi import SolapiMessageService -from solapi.model import Message +from solapi.model import RequestMessage from solapi.model.request.storage import FileTypeEnum # API 키와 API Secret을 설정합니다 @@ -23,7 +23,7 @@ print(f"File ID: {file_response.file_id}") # MMS 메시지를 생성하고 발송합니다 - message = Message( + message = RequestMessage( from_="발신번호", # 발신번호 (등록된 발신번호만 사용 가능) to="수신번호", # 수신번호 # subject="MMS 제목", # MMS 제목, 제목을 지정하지 않는다면 필요하지 않습니다. diff --git a/examples/simple/send_sms.py b/examples/simple/send_sms.py index d2aeb28..7f1859a 100644 --- a/examples/simple/send_sms.py +++ b/examples/simple/send_sms.py @@ -1,5 +1,5 @@ from solapi import SolapiMessageService -from solapi.model import Message +from solapi.model import RequestMessage # API 키와 API Secret을 설정합니다 message_service = SolapiMessageService( @@ -7,7 +7,7 @@ ) # 단일 메시지를 생성합니다 -message = Message( +message = RequestMessage( from_="발신번호", # 발신번호 (등록된 발신번호만 사용 가능) to="수신번호", # 수신번호 text="안녕하세요! SOLAPI Python SDK를 사용한 SMS 발송 예제입니다.", diff --git a/examples/simple/send_sms_with_reservation.py b/examples/simple/send_sms_with_reservation.py index 3e58e64..b542b81 100644 --- a/examples/simple/send_sms_with_reservation.py +++ b/examples/simple/send_sms_with_reservation.py @@ -1,7 +1,7 @@ from datetime import datetime from solapi import SolapiMessageService -from solapi.model import Message, SendRequestConfig +from solapi.model import RequestMessage, SendRequestConfig # API 키와 API Secret을 설정합니다 message_service = SolapiMessageService( @@ -9,7 +9,7 @@ ) # 단일 메시지를 생성합니다 -message = Message( +message = RequestMessage( from_="발신번호", # 발신번호 (등록된 발신번호만 사용 가능) to="수신번호", # 수신번호 text="안녕하세요! SOLAPI Python SDK를 사용한 SMS 발송 예제입니다.", diff --git a/solapi/model/kakao/kakao_option.py b/solapi/model/kakao/kakao_option.py index 19e089d..348b665 100644 --- a/solapi/model/kakao/kakao_option.py +++ b/solapi/model/kakao/kakao_option.py @@ -1,10 +1,10 @@ +from collections.abc import Mapping from typing import Optional -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, field_validator from pydantic.alias_generators import to_camel -# TODO: Request, Response용 모델을 별도로 분리해야 함 class KakaoOption(BaseModel): pf_id: Optional[str] = None template_id: Optional[str] = None @@ -13,3 +13,10 @@ class KakaoOption(BaseModel): image_id: Optional[str] = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + @field_validator("variables", mode="before") + @classmethod + def stringify_values(cls, v: Mapping[str, object]): + if isinstance(v, Mapping): + # 모든 value를 str로 캐스팅 + return {k: str(val) for k, val in v.items()} diff --git a/solapi/model/rcs/rcs_options.py b/solapi/model/rcs/rcs_options.py index ceb8859..ddf3f8e 100644 --- a/solapi/model/rcs/rcs_options.py +++ b/solapi/model/rcs/rcs_options.py @@ -1,6 +1,6 @@ from datetime import datetime from enum import Enum -from typing import Optional +from typing import Any, Optional from pydantic import BaseModel, ConfigDict from pydantic.alias_generators import to_camel @@ -82,7 +82,8 @@ class RcsOption(BaseModel): mms_type: Optional[RcsMmsType] = None commercial_type: Optional[bool] = None disable_sms: Optional[bool] = False - additional_body: Optional[list[RcsAdditionalBody]] = None + additional_body: Optional[Any] = None + # additional_body: Optional[list[RcsAdditionalBody]] = None buttons: Optional[list[RcsButton]] = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/request/kakao/kakao_option.py b/solapi/model/request/kakao/kakao_option.py new file mode 100644 index 0000000..6656e2b --- /dev/null +++ b/solapi/model/request/kakao/kakao_option.py @@ -0,0 +1,17 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + +from solapi.model.kakao.kakao_button import KakaoButton + + +class KakaoOption(BaseModel): + pf_id: str + template_id: Optional[str] = None + variables: Optional[dict[str, str]] = None + disable_sms: bool = False + image_id: Optional[str] = None + buttons: Optional[list[KakaoButton]] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/request/message.py b/solapi/model/request/message.py new file mode 100644 index 0000000..486dba5 --- /dev/null +++ b/solapi/model/request/message.py @@ -0,0 +1,60 @@ +from typing import Any, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + +from solapi.model import KakaoOption +from solapi.model.message_type import MessageType +from solapi.model.rcs.rcs_options import RcsOption + + +class FileIdsType(BaseModel): + file_ids: Optional[list[str]] = None + + +class Message(BaseModel): + from_: Optional[str] = Field( + default=None, serialization_alias="from", validation_alias="from" + ) + to: Union[str, list[str]] + text: Optional[str] = None + image_id: Optional[str] = Field( + default=None, serialization_alias="imageId", validation_alias="imageId" + ) + country: str = "82" + message_id: Optional[str] = Field( + default=None, serialization_alias="messageId", validation_alias="messageId" + ) + group_id: Optional[str] = Field( + default=None, serialization_alias="groupId", validation_alias="groupId" + ) + type: Optional[MessageType] = None + auto_type_detect: Optional[bool] = Field( + default=True, + serialization_alias="autoTypeDetect", + validation_alias="autoTypeDetect", + ) + subject: Optional[str] = None + replacements: Optional[list[dict[str, Any]]] = None + custom_fields: Optional[dict[str, str]] = Field( + default=None, + serialization_alias="customFields", + validation_alias="customFields", + ) + kakao_options: Optional[KakaoOption] = Field( + default=None, + serialization_alias="kakaoOptions", + validation_alias="kakaoOptions", + ) + rcs_options: Optional[RcsOption] = Field( + default=None, serialization_alias="rcsOptions", validation_alias="rcsOptions" + ) + fax_options: Optional[FileIdsType] = Field( + default=None, serialization_alias="faxOptions", validation_alias="faxOptions" + ) + + model_config = ConfigDict( + extra="ignore", + populate_by_name=True, + alias_generator=to_camel, + ) diff --git a/solapi/model/request/send_message_request.py b/solapi/model/request/send_message_request.py index 37bf575..a71ad0d 100644 --- a/solapi/model/request/send_message_request.py +++ b/solapi/model/request/send_message_request.py @@ -5,8 +5,8 @@ from pydantic import BaseModel, ConfigDict from pydantic.alias_generators import to_camel -from solapi.model.message import Message from solapi.model.request import VERSION +from solapi.model.request.message import Message class SendRequestConfig(BaseModel): diff --git a/solapi/model/response/groups/get_group_messages.py b/solapi/model/response/groups/get_group_messages.py index 36a9738..f5e53f5 100644 --- a/solapi/model/response/groups/get_group_messages.py +++ b/solapi/model/response/groups/get_group_messages.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, ConfigDict from pydantic.alias_generators import to_camel -from solapi.model import Message +from solapi.model.response.message import Message class GetGroupMessagesResponse(BaseModel): diff --git a/solapi/model/message.py b/solapi/model/response/message.py similarity index 71% rename from solapi/model/message.py rename to solapi/model/response/message.py index ebb9a11..d74e8c6 100644 --- a/solapi/model/message.py +++ b/solapi/model/response/message.py @@ -1,57 +1,14 @@ from datetime import datetime -from enum import Enum from typing import Any, Optional, Union from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel from solapi.model import KakaoOption +from solapi.model.message_type import MessageType from solapi.model.rcs.rcs_options import RcsOption -class MessageType(Enum): - """ - 메시지 유형(단문 문자, 장문 문자, 알림톡 등) - SMS: 단문 문자 - LMS: 장문 문자 - MMS: 사진 문자 - ATA: 알림톡 - CTA: 친구톡 - CTI: 사진 한장이 포함된 친구톡 - NSA: 네이버 스마트알림(톡톡) - RCS_SMS: RCS 단문 문자 - RCS_LMS: RCS 장문 문자 - RCS_MMS: RCS 사진 문자 - RCS_TPL: RCS 템플릿 - RCS_ITPL: RCS 이미지 템플릿 - RCS_LTPL: RCS LMS 템플릿 문자 - FAX: 팩스 - VOICE: 음성문자(TTS) - """ - - SMS = "SMS" - LMS = "LMS" - MMS = "MMS" - ATA = "ATA" - CTA = "CTA" - CTI = "CTI" - NSA = "NSA" - RCS_SMS = "RCS_SMS" - RCS_LMS = "RCS_LMS" - RCS_MMS = "RCS_MMS" - RCS_TPL = "RCS_TPL" - RCS_ITPL = "RCS_ITPL" - RCS_LTPL = "RCS_LTPL" - FAX = "FAX" - VOICE = "VOICE" - - def __str__(self) -> str: - return self.value - - def __repr__(self): - return repr(self.value) - - class FileIdsType(BaseModel): file_ids: Optional[list[str]] = None diff --git a/solapi/model/response/messages/get_messages.py b/solapi/model/response/messages/get_messages.py index c6cb61b..91fcb02 100644 --- a/solapi/model/response/messages/get_messages.py +++ b/solapi/model/response/messages/get_messages.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, ConfigDict from pydantic.alias_generators import to_camel -from solapi.model import Message +from solapi.model.response.message import Message class GetMessagesResponse(BaseModel): diff --git a/solapi/services/message_service.py b/solapi/services/message_service.py index 72c5ee7..078607e 100644 --- a/solapi/services/message_service.py +++ b/solapi/services/message_service.py @@ -8,12 +8,12 @@ from solapi.lib.authenticator import AuthenticationParameter from solapi.lib.fetcher import RequestMethod, default_fetcher from solapi.lib.string_date_transfer import format_with_transfer -from solapi.model.message import Message from solapi.model.request.groups.get_groups import ( GetGroupsCrteriaType, GetGroupsFinalizeRequest, GetGroupsRequest, ) +from solapi.model.request.message import Message as RequestMessage from solapi.model.request.messages.get_messages import GetMessagesRequest from solapi.model.request.send_message_request import ( SendMessageRequest, @@ -33,7 +33,7 @@ class SolapiMessageService: """Solapi Message Service for handling message-related operations. This class provides methods to interact with the Solapi API for sending various types of messages - (SMS, LMS, MMS, 알림톡, 친구톡, RCS, etc.), managing message groups, uploading files, + (SMS, LMS, MMS, 알림톡, 친구톡, RCS, etc.), managing message groups, uploading files, and checking account balance. """ @@ -52,7 +52,7 @@ def __init__(self, api_key: str, api_secret: str): def send( self, - messages: Union[list[Message], Message], + messages: Union[list[RequestMessage], RequestMessage], request_config: Optional[SendRequestConfig] = None, ) -> SendMessageResponse: """Send one or more messages using the Solapi API. @@ -61,11 +61,11 @@ def send( It supports sending a single message or multiple messages in one request (대량 발송). Args: - messages: A single Message object or a list of Message objects to send. - Each Message object contains recipient (수신번호), sender (발신번호), content (내용), + messages: A single RequestMessage object or a list of RequestMessage objects to send. + Each RequestMessage object contains recipient (수신번호), sender (발신번호), content (내용), and other message details. request_config: Optional configuration for the send request. - Can include app_id, allow_duplicates (중복 수신번호 허용), + Can include app_id, allow_duplicates (중복 수신번호 허용), scheduled_date (예약 발송), and other settings. Returns: @@ -73,18 +73,18 @@ def send( and other details about the sent messages. Raises: - TypeError: If messages parameter is not a Message object or list of Message objects. + TypeError: If messages parameter is not a RequestMessage object or list of RequestMessage objects. ValueError: If no valid messages are provided. MessageNotReceivedError: If all messages failed to be registered. """ payload = [] - if isinstance(messages, Message): + if isinstance(messages, RequestMessage): payload.append(messages) elif isinstance(messages, list): for message in messages: - if isinstance(message, Message) is not True: + if isinstance(message, RequestMessage) is not True: raise TypeError( - "The messages parameter must be an instance of Message." + "The messages parameter must be an instance of RequestMessage." ) payload.extend(messages) @@ -258,7 +258,7 @@ def get_messages( Args: query: Optional query parameters to filter the messages. - Can include criteria for message status (상태), date range (기간), + Can include criteria for message status (상태), date range (기간), recipient (수신번호), sender (발신번호), etc. Returns: diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..06403a8 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,64 @@ +# SOLAPI Python SDK Tests + +This directory contains unit tests for the SOLAPI Python SDK. The tests cover all the functionality demonstrated in the examples folder. + +## Prerequisites + +- Python 3.9 or higher +- pytest + +## Setup + +1. Install the required dependencies: + ```bash + pip install pytest + ``` + +2. Set up environment variables for API credentials and test phone numbers: + ```bash + export SOLAPI_API_KEY="your_api_key" + export SOLAPI_API_SECRET="your_api_secret" + export SOLAPI_SENDER="your_registered_sender_number" + export SOLAPI_RECIPIENT="recipient_phone_number" + export SOLAPI_KAKAO_PF_ID="your_kakao_business_channel_id" + export SOLAPI_KAKAO_TEMPLATE_ID="your_kakao_template_id" + ``` + + Alternatively, you can modify the `conftest.py` file to hardcode these values for testing purposes. + +## Running Tests + +To run all tests: +```bash +pytest +``` + +To run a specific test file: +```bash +pytest tests/test_balance.py +``` + +To run a specific test: +```bash +pytest tests/test_simple_send.py::TestSimpleSend::test_send_sms +``` + +To run tests with verbose output: +```bash +pytest -v +``` + +## Test Files + +- `test_balance.py`: Tests for checking account balance +- `test_group.py`: Tests for message group operations +- `test_messages.py`: Tests for retrieving message information +- `test_simple_send.py`: Tests for sending various types of messages (SMS, MMS, Kakao Alimtalk, etc.) +- `test_storage.py`: Tests for file upload operations + +## Notes + +- The tests are designed to work with valid API credentials. If you provide invalid credentials, the tests will fail. +- Some tests (like Kakao Alimtalk) require specific setup in your SOLAPI account. +- The MMS and storage tests require the example image file to exist at `examples/images/example.jpg`. +- The tests are designed to be independent of each other, but they may create resources (like message groups) in your SOLAPI account. \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a9cdaa9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,65 @@ +import os + +import pytest + +from solapi import SolapiMessageService + + +@pytest.fixture +def api_credentials(): + """ + Fixture to provide API credentials. + + Returns: + tuple: (api_key, api_secret) + """ + api_key = os.environ.get("SOLAPI_API_KEY", "YOUR_API_KEY") + api_secret = os.environ.get("SOLAPI_API_SECRET", "YOUR_API_SECRET") + return api_key, api_secret + + +@pytest.fixture +def message_service(api_credentials): + """ + Fixture to provide a configured SolapiMessageService instance. + + Args: + api_credentials: Tuple of (api_key, api_secret) + + Returns: + SolapiMessageService: Configured service instance + """ + api_key, api_secret = api_credentials + return SolapiMessageService(api_key=api_key, api_secret=api_secret) + + +@pytest.fixture +def test_phone_numbers(): + """ + Fixture to provide test phone numbers. + + Returns: + dict: Dictionary containing sender and recipient phone numbers + """ + return { + "sender": os.environ.get("SOLAPI_SENDER", "발신번호"), + "recipient": os.environ.get("SOLAPI_RECIPIENT", "수신번호"), + } + + +@pytest.fixture +def test_kakao_options(): + """ + Fixture to provide test Kakao options. + + Returns: + dict: Dictionary containing Kakao channel ID and template ID + """ + return { + "pf_id": os.environ.get( + "SOLAPI_KAKAO_PF_ID", "계정에 등록된 카카오 비즈니스 채널ID" + ), + "template_id": os.environ.get( + "SOLAPI_KAKAO_TEMPLATE_ID", "계정에 등록된 카카오 알림톡 템플릿 ID" + ), + } diff --git a/tests/test_balance.py b/tests/test_balance.py new file mode 100644 index 0000000..8f9e2c6 --- /dev/null +++ b/tests/test_balance.py @@ -0,0 +1,33 @@ +from solapi.model.response.balance.get_balance import GetBalanceResponse + + +class TestBalance: + """Test cases for balance-related functionality.""" + + def test_get_balance(self, message_service): + """ + Test getting account balance. + + This test verifies that the get_balance method returns a valid response + with balance and point information. + + Args: + message_service: The SolapiMessageService fixture + """ + # Get balance + response = message_service.get_balance() + + # Verify response type + assert isinstance(response, GetBalanceResponse) + + # Verify response has required fields + assert hasattr(response, "balance") + assert hasattr(response, "point") + + # Verify balance and point are numeric values + assert isinstance(response.balance, (int, float)) + assert isinstance(response.point, (int, float)) + + # Print balance information for verification + print(f"Current balance: {response.balance} KRW") + print(f"Current points: {response.point} points") diff --git a/tests/test_group.py b/tests/test_group.py new file mode 100644 index 0000000..ce2ca32 --- /dev/null +++ b/tests/test_group.py @@ -0,0 +1,106 @@ +import pytest + +from solapi.model.response.groups.get_group_messages import GetGroupMessagesResponse +from solapi.model.response.groups.get_groups import GetGroupsResponse + + +class TestGroup: + """Test cases for group-related functionality.""" + + def test_get_groups(self, message_service): + """ + Test getting message groups. + + This test verifies that the get_groups method returns a valid response + with group information. + + Args: + message_service: The SolapiMessageService fixture + """ + # Get groups + response = message_service.get_groups() + + # Verify response type + assert isinstance(response, GetGroupsResponse) + + # Verify response has required fields + assert hasattr(response, "group_list") + + # Print group information for verification + print(f"Number of groups: {len(response.group_list)}") + + # If there are groups, verify the structure of the first group + if response.group_list: + group_id = next(iter(response.group_list)) + group = response.group_list[group_id] + + print(f"Group ID: {group_id}") + print(f"Created: {group.date_created}") + print(f"Status: {group.status}") + + # Test get_group method with this group ID + self.test_get_group(message_service, group_id) + + def test_get_group(self, message_service, group_id=None): + """ + Test getting a specific message group. + + This test verifies that the get_group method returns a valid response + with detailed information about a specific group. + + Args: + message_service: The SolapiMessageService fixture + group_id: Optional group ID to test. If not provided, the test will be skipped. + """ + if group_id is None: + pytest.skip("No group ID provided") + + # Get specific group + response = message_service.get_group(group_id) + + # Verify response has required fields + assert hasattr(response, "group_id") + assert hasattr(response, "date_created") + assert hasattr(response, "status") + + # Print group details for verification + print(f"Group details - ID: {response.group_id}") + print(f"Group details - Created: {response.date_created}") + print(f"Group details - Status: {response.status}") + + # Test get_group_messages method with this group ID + self.test_get_group_messages(message_service, group_id) + + def test_get_group_messages(self, message_service, group_id=None): + """ + Test getting messages in a specific group. + + This test verifies that the get_group_messages method returns a valid response + with messages belonging to a specific group. + + Args: + message_service: The SolapiMessageService fixture + group_id: Optional group ID to test. If not provided, the test will be skipped. + """ + if group_id is None: + pytest.skip("No group ID provided") + + # Get messages in the group + response = message_service.get_group_messages(group_id) + + # Verify response type + assert isinstance(response, GetGroupMessagesResponse) + + # Verify response has required fields + assert hasattr(response, "messages") + + # Print message information for verification + print(f"Number of messages in group: {len(response.messages)}") + + # If there are messages, verify the structure of the first message + if response.messages: + message = response.messages[0] + print(f"Message ID: {message.message_id}") + print(f"Status: {message.status}") + print(f"To: {message.to}") + print(f"From: {message.from_}") diff --git a/tests/test_messages.py b/tests/test_messages.py new file mode 100644 index 0000000..ad04494 --- /dev/null +++ b/tests/test_messages.py @@ -0,0 +1,86 @@ +from datetime import datetime, timedelta + +from solapi.model.request.messages.get_messages import GetMessagesRequest +from solapi.model.response.messages.get_messages import GetMessagesResponse + + +class TestMessages: + """Test cases for message-related functionality.""" + + def test_get_messages(self, message_service): + """ + Test getting messages without any filters. + + This test verifies that the get_messages method returns a valid response + with message information. + + Args: + message_service: The SolapiMessageService fixture + """ + # Get messages + response = message_service.get_messages() + + # Verify response type + assert isinstance(response, GetMessagesResponse) + + # Verify response has required fields + assert hasattr(response, "messages") + assert hasattr(response, "total_count") + + # Print message information for verification + print(f"Total messages: {response.total_count}") + print(f"Messages in response: {len(response.messages)}") + + # If there are messages, verify the structure of the first message + if response.messages: + message = response.messages[0] + print(f"Message ID: {message.message_id}") + print(f"Status: {message.status}") + print(f"To: {message.to}") + print(f"From: {message.from_}") + + def test_get_messages_with_date_filter(self, message_service): + """ + Test getting messages with date filters. + + This test verifies that the get_messages method with date filters + returns a valid response with filtered message information. + + Args: + message_service: The SolapiMessageService fixture + """ + # Create date range for the last 7 days + end_date = datetime.now() + start_date = end_date - timedelta(days=7) + + # Format dates as strings + start_date_str = start_date.strftime("%Y-%m-%d") + end_date_str = end_date.strftime("%Y-%m-%d") + + # Create request with date filter + request = GetMessagesRequest(start_date=start_date_str, end_date=end_date_str) + + # Get messages with filter + response = message_service.get_messages(request) + + # Verify response type + assert isinstance(response, GetMessagesResponse) + + # Verify response has required fields + assert hasattr(response, "messages") + assert hasattr(response, "total_count") + + # Print message information for verification + print( + f"Total messages in date range {start_date_str} to {end_date_str}: {response.total_count}" + ) + print(f"Messages in response: {len(response.messages)}") + + # If there are messages, verify the structure of the first message + if response.messages: + message = response.messages[0] + print(f"Message ID: {message.message_id}") + print(f"Status: {message.status}") + print(f"To: {message.to}") + print(f"From: {message.from_}") + print(f"Date: {message.date_created}") diff --git a/tests/test_simple_send.py b/tests/test_simple_send.py new file mode 100644 index 0000000..de23200 --- /dev/null +++ b/tests/test_simple_send.py @@ -0,0 +1,281 @@ +from datetime import datetime, timedelta +from pathlib import Path + +import pytest + +from solapi.model import RequestMessage, SendRequestConfig +from solapi.model.kakao.kakao_option import KakaoOption +from solapi.model.request.storage import FileTypeEnum +from solapi.model.response.send_message_response import SendMessageResponse + + +class TestSimpleSend: + """Test cases for simple message sending functionality.""" + + def test_send_sms(self, message_service, test_phone_numbers): + """ + Test sending a simple SMS message. + + This test verifies that the send method with a simple SMS message + returns a valid response. + + Args: + message_service: The SolapiMessageService fixture + test_phone_numbers: Dictionary with sender and recipient phone numbers + """ + # Create message + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트] SOLAPI Python SDK를 사용한 SMS 발송 테스트입니다.", + ) + + # Send message + response = message_service.send(message) + + # Verify response type + assert isinstance(response, SendMessageResponse) + + # Verify response has required fields + assert hasattr(response, "group_info") + assert hasattr(response.group_info, "group_id") + assert hasattr(response.group_info, "count") + + # Verify message was sent successfully + assert response.group_info.count.total > 0 + assert response.group_info.count.registered_success > 0 + + # Print response information for verification + print(f"Group ID: {response.group_info.group_id}") + print(f"Total messages: {response.group_info.count.total}") + print(f"Successful messages: {response.group_info.count.registered_success}") + print(f"Failed messages: {response.group_info.count.registered_failed}") + + return response.group_info.group_id + + def test_send_mms(self, message_service, test_phone_numbers): + """ + Test sending an MMS message with an image. + + This test verifies that the upload_file and send methods for MMS + return valid responses. + + Args: + message_service: The SolapiMessageService fixture + test_phone_numbers: Dictionary with sender and recipient phone numbers + """ + # Get the path to the example image + image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example.jpg" + ) + + # Skip test if image doesn't exist + if not image_path.exists(): + pytest.skip(f"Test image not found at {image_path}") + + # Upload image file + file_response = message_service.upload_file( + file_path=str(image_path), + upload_type=FileTypeEnum.MMS, + ) + + # Verify file upload response + assert hasattr(file_response, "file_id") + print(f"Uploaded file ID: {file_response.file_id}") + + # Create MMS message + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트] SOLAPI Python SDK를 사용한 MMS 발송 테스트입니다.", + subject="MMS 테스트", + image_id=file_response.file_id, + ) + + # Send message + response = message_service.send(message) + + # Verify response type + assert isinstance(response, SendMessageResponse) + + # Verify response has required fields + assert hasattr(response, "group_info") + assert hasattr(response.group_info, "group_id") + assert hasattr(response.group_info, "count") + + # Verify message was sent successfully + assert response.group_info.count.total > 0 + assert response.group_info.count.registered_success > 0 + + # Print response information for verification + print(f"Group ID: {response.group_info.group_id}") + print(f"Total messages: {response.group_info.count.total}") + print(f"Successful messages: {response.group_info.count.registered_success}") + print(f"Failed messages: {response.group_info.count.registered_failed}") + + return response.group_info.group_id + + def test_send_kakao_alimtalk( + self, message_service, test_phone_numbers, test_kakao_options + ): + """ + Test sending a Kakao Alimtalk message. + + This test verifies that the send method with Kakao options + returns a valid response. + + Args: + message_service: The SolapiMessageService fixture + test_phone_numbers: Dictionary with sender and recipient phone numbers + test_kakao_options: Dictionary with Kakao channel ID and template ID + """ + # Create Kakao options + kakao_option = KakaoOption( + pf_id=test_kakao_options["pf_id"], + template_id=test_kakao_options["template_id"], + ) + + # Create message + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + kakao_options=kakao_option, + ) + + # Send message + try: + response = message_service.send(message) + + # Verify response type + assert isinstance(response, SendMessageResponse) + + # Verify response has required fields + assert hasattr(response, "group_info") + assert hasattr(response.group_info, "group_id") + assert hasattr(response.group_info, "count") + + # Verify message was sent successfully + assert response.group_info.count.total > 0 + assert response.group_info.count.registered_success > 0 + + # Print response information for verification + print(f"Group ID: {response.group_info.group_id}") + print(f"Total messages: {response.group_info.count.total}") + print( + f"Successful messages: {response.group_info.count.registered_success}" + ) + print(f"Failed messages: {response.group_info.count.registered_failed}") + + return response.group_info.group_id + except Exception as e: + # This test may fail if Kakao template is not properly set up + pytest.skip(f"Kakao Alimtalk test skipped: {str(e)}") + + def test_send_many(self, message_service, test_phone_numbers): + """ + Test sending multiple messages at once. + + This test verifies that the send method with multiple messages + returns a valid response. + + Args: + message_service: The SolapiMessageService fixture + test_phone_numbers: Dictionary with sender and recipient phone numbers + """ + # Create multiple messages + messages = [ + RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트1] SOLAPI Python SDK를 사용한 대량 발송 테스트입니다.", + ), + RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트2] SOLAPI Python SDK를 사용한 대량 발송 테스트입니다.", + ), + RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트3] SOLAPI Python SDK를 사용한 대량 발송 테스트입니다.", + ), + ] + + # Create config with duplicates allowed + config = SendRequestConfig(allow_duplicates=True) + + # Send messages + response = message_service.send(messages, config) + + # Verify response type + assert isinstance(response, SendMessageResponse) + + # Verify response has required fields + assert hasattr(response, "group_info") + assert hasattr(response.group_info, "group_id") + assert hasattr(response.group_info, "count") + + # Verify messages were sent successfully + assert response.group_info.count.total == 3 + assert response.group_info.count.registered_success > 0 + + # Print response information for verification + print(f"Group ID: {response.group_info.group_id}") + print(f"Total messages: {response.group_info.count.total}") + print(f"Successful messages: {response.group_info.count.registered_success}") + print(f"Failed messages: {response.group_info.count.registered_failed}") + + # Check for failed messages + if response.failed_message_list: + print("\nFailed messages:") + for failed in response.failed_message_list: + print(f"To: {failed.message.to}") + print(f"Error: {failed.error.message}") + + return response.group_info.group_id + + def test_send_with_reservation(self, message_service, test_phone_numbers): + """ + Test sending a message with a future reservation time. + + This test verifies that the send method with a scheduled date + returns a valid response. + + Args: + message_service: The SolapiMessageService fixture + test_phone_numbers: Dictionary with sender and recipient phone numbers + """ + # Create message + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트] SOLAPI Python SDK를 사용한 예약 발송 테스트입니다.", + ) + + # Create config with scheduled date (10 minutes in the future) + scheduled_date = datetime.now() + timedelta(minutes=10) + config = SendRequestConfig(scheduled_date=scheduled_date) + + # Send message with reservation + response = message_service.send(message, config) + + # Verify response type + assert isinstance(response, SendMessageResponse) + + # Verify response has required fields + assert hasattr(response, "group_info") + assert hasattr(response.group_info, "group_id") + assert hasattr(response.group_info, "count") + + # Verify message was scheduled successfully + assert response.group_info.count.total > 0 + assert response.group_info.count.registered_success > 0 + + # Print response information for verification + print(f"Group ID: {response.group_info.group_id}") + print(f"Scheduled date: {scheduled_date}") + print(f"Total messages: {response.group_info.count.total}") + print(f"Successful messages: {response.group_info.count.registered_success}") + print(f"Failed messages: {response.group_info.count.registered_failed}") + + return response.group_info.group_id diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..3c8a74b --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,130 @@ +from pathlib import Path + +import pytest + +from solapi.model.request.storage import FileTypeEnum +from solapi.model.response.storage import FileUploadResponse + + +class TestStorage: + """Test cases for storage-related functionality.""" + + def test_upload_file_mms(self, message_service): + """ + Test uploading a file for MMS. + + This test verifies that the upload_file method returns a valid response + when uploading a file for MMS. + + Args: + message_service: The SolapiMessageService fixture + """ + # Get the path to the example image + image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example.jpg" + ) + + # Skip test if image doesn't exist + if not image_path.exists(): + pytest.skip(f"Test image not found at {image_path}") + + # Upload file + response = message_service.upload_file( + file_path=str(image_path), + upload_type=FileTypeEnum.MMS, + ) + + # Verify response type + assert isinstance(response, FileUploadResponse) + + # Verify response has required fields + assert hasattr(response, "file_id") + assert hasattr(response, "name") + assert hasattr(response, "date_created") + + # Print file information for verification + print(f"File ID: {response.file_id}") + print(f"File name: {response.name}") + print(f"File created: {response.date_created}") + + return response.file_id + + def test_upload_file_kakao(self, message_service): + """ + Test uploading a file for Kakao. + + This test verifies that the upload_file method returns a valid response + when uploading a file for Kakao. + + Args: + message_service: The SolapiMessageService fixture + """ + # Get the path to the example image + image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example.jpg" + ) + + # Skip test if image doesn't exist + if not image_path.exists(): + pytest.skip(f"Test image not found at {image_path}") + + # Upload file + response = message_service.upload_file( + file_path=str(image_path), + upload_type=FileTypeEnum.KAKAO, + ) + + # Verify response type + assert isinstance(response, FileUploadResponse) + + # Verify response has required fields + assert hasattr(response, "file_id") + assert hasattr(response, "name") + assert hasattr(response, "date_created") + + # Print file information for verification + print(f"File ID: {response.file_id}") + print(f"File name: {response.name}") + print(f"File created: {response.date_created}") + + return response.file_id + + def test_upload_file_document(self, message_service): + """ + Test uploading a file as a document. + + This test verifies that the upload_file method returns a valid response + when uploading a file as a document. + + Args: + message_service: The SolapiMessageService fixture + """ + # Get the path to the example image (using as a document for test purposes) + image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example.jpg" + ) + + # Skip test if image doesn't exist + if not image_path.exists(): + pytest.skip(f"Test image not found at {image_path}") + + # Upload file + response = message_service.upload_file( + file_path=str(image_path), + upload_type=FileTypeEnum.DOCUMENT, + ) + + # Verify response type + assert isinstance(response, FileUploadResponse) + + # Verify response has required fields + assert hasattr(response, "file_id") + assert hasattr(response, "name") + assert hasattr(response, "date_created") + + # Print file information for verification + print(f"File ID: {response.file_id}") + print(f"File name: {response.name}") + print(f"File created: {response.date_created}") + + return response.file_id From 718ca5184f04d133624c4d05a32a62a9a324d54d Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 9 May 2025 11:50:31 +0900 Subject: [PATCH 37/60] Update pytest code, Add cancel_scheduled_message method in message_service.py, Fix errors in rcs_options.py --- solapi/lib/string_date_transfer.py | 12 +++- solapi/model/rcs/rcs_options.py | 23 ++++---- solapi/model/request/__init__.py | 2 +- solapi/model/request/messages/get_messages.py | 25 +++----- solapi/services/message_service.py | 17 +++++- tests/test_group.py | 21 +++---- tests/test_messages.py | 39 +++++------- tests/test_simple_send.py | 59 +++++++++++++++---- tests/test_storage.py | 14 ++--- 9 files changed, 125 insertions(+), 87 deletions(-) diff --git a/solapi/lib/string_date_transfer.py b/solapi/lib/string_date_transfer.py index 164b6a2..8b1c257 100644 --- a/solapi/lib/string_date_transfer.py +++ b/solapi/lib/string_date_transfer.py @@ -80,9 +80,15 @@ def string_date_transfer(value: Union[str, datetime]): if isinstance(value, str): try: value = parse_iso(value) - except Exception as e: - raise InvalidDateError("Invalid Date") from e - + except InvalidDateError: # parse_iso가 실패한 경우 + try: + # "YYYY-MM-DD HH:MM:SS" 형식 시도 + value = datetime.strptime(value, "%Y-%m-%d %H:%M:%S") + except ValueError as e: + # 두 형식 모두 실패한 경우 원래 에러 발생 + raise InvalidDateError( + "Invalid Date format. Expected ISO 8601 or YYYY-MM-DD HH:MM:SS" + ) from e return value diff --git a/solapi/model/rcs/rcs_options.py b/solapi/model/rcs/rcs_options.py index ddf3f8e..8663a32 100644 --- a/solapi/model/rcs/rcs_options.py +++ b/solapi/model/rcs/rcs_options.py @@ -1,6 +1,6 @@ from datetime import datetime from enum import Enum -from typing import Any, Optional +from typing import Optional from pydantic import BaseModel, ConfigDict from pydantic.alias_generators import to_camel @@ -27,15 +27,6 @@ def __repr__(self): return repr(self.value) -class RcsAdditionalBody(BaseModel): - title: str - description: str - image_id: Optional[str] = None - buttons: Optional[dict[str, str]] = None - - model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) - - class RcsButtonType(str, Enum): """ 'WL'(웹링크), 'ML'(지도[좌표]), 'MQ'(지도[쿼리]), 'MR'(위치공유), @@ -74,6 +65,15 @@ class RcsButton(BaseModel): model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) +class RcsAdditionalBody(BaseModel): + title: str + description: str + image_id: Optional[str] = None + buttons: Optional[list[RcsButton]] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + class RcsOption(BaseModel): brand_id: Optional[str] = None template_id: Optional[str] = None @@ -82,8 +82,7 @@ class RcsOption(BaseModel): mms_type: Optional[RcsMmsType] = None commercial_type: Optional[bool] = None disable_sms: Optional[bool] = False - additional_body: Optional[Any] = None - # additional_body: Optional[list[RcsAdditionalBody]] = None + additional_body: Optional[list[RcsAdditionalBody]] = None buttons: Optional[list[RcsButton]] = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/request/__init__.py b/solapi/model/request/__init__.py index d08287d..39bc86c 100644 --- a/solapi/model/request/__init__.py +++ b/solapi/model/request/__init__.py @@ -1,2 +1,2 @@ # NOTE: Python SDK가 업데이트 될 때마다 Version도 갱신해야 함! -VERSION = "python/5.0.0" +VERSION = "python/5.0.1" diff --git a/solapi/model/request/messages/get_messages.py b/solapi/model/request/messages/get_messages.py index 97bae1b..85246ee 100644 --- a/solapi/model/request/messages/get_messages.py +++ b/solapi/model/request/messages/get_messages.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Optional, Union -from pydantic import BaseModel, ConfigDict, Field, computed_field +from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic.alias_generators import to_camel from solapi.lib.string_date_transfer import format_with_transfer @@ -24,18 +24,11 @@ class GetMessagesRequest(BaseModel): model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) - @computed_field(alias="startDate") - @property - def formatted_start_date(self) -> Union[str, None]: - if self.start_date is not None: - return format_with_transfer(self.start_date) - else: - return None - - @computed_field(alias="endDate") - @property - def formatted_end_date(self) -> Union[str, None]: - if self.end_date is not None: - return format_with_transfer(self.end_date) - else: - return None + @field_validator("start_date", "end_date", mode="before") + @classmethod + def format_dates( + cls, value: Union[str, datetime, None] + ) -> Union[str, datetime, None]: + if isinstance(value, str): + return format_with_transfer(value) + return value diff --git a/solapi/services/message_service.py b/solapi/services/message_service.py index 078607e..01b8904 100644 --- a/solapi/services/message_service.py +++ b/solapi/services/message_service.py @@ -88,8 +88,6 @@ def send( ) payload.extend(messages) - else: - raise TypeError("Invalid message type") if len(payload) == 0: raise ValueError("The data must have at least one message.") @@ -168,6 +166,21 @@ def upload_file( ) return FileUploadResponse.model_validate(response) + def cancel_scheduled_message(self, group_id: str) -> GroupMessageResponse: + """Cancel a reserved message. + + This method cancels a message that was previously scheduled to be sent. + It requires the group_id of the message to be canceled. + """ + response = default_fetcher( + self.auth_info, + request={ + "url": f"{self.base_url}/messages/v4/groups/{group_id}/schedule", + "method": RequestMethod.DELETE, + }, + ) + return GroupMessageResponse.model_validate(response) + def get_groups(self, query: Optional[GetGroupsRequest] = None) -> GetGroupsResponse: """Retrieve message groups (메시지 그룹) from the Solapi API. diff --git a/tests/test_group.py b/tests/test_group.py index ce2ca32..868ebd3 100644 --- a/tests/test_group.py +++ b/tests/test_group.py @@ -2,12 +2,13 @@ from solapi.model.response.groups.get_group_messages import GetGroupMessagesResponse from solapi.model.response.groups.get_groups import GetGroupsResponse +from solapi.services.message_service import SolapiMessageService class TestGroup: """Test cases for group-related functionality.""" - def test_get_groups(self, message_service): + def test_get_groups(self, message_service: SolapiMessageService): """ Test getting message groups. @@ -41,7 +42,7 @@ def test_get_groups(self, message_service): # Test get_group method with this group ID self.test_get_group(message_service, group_id) - def test_get_group(self, message_service, group_id=None): + def test_get_group(self, message_service: SolapiMessageService, group_id=None): """ Test getting a specific message group. @@ -92,15 +93,15 @@ def test_get_group_messages(self, message_service, group_id=None): assert isinstance(response, GetGroupMessagesResponse) # Verify response has required fields - assert hasattr(response, "messages") + assert hasattr(response, "message_list") # Print message information for verification - print(f"Number of messages in group: {len(response.messages)}") + print(f"Number of messages in group: {len(response.message_list)}") # If there are messages, verify the structure of the first message - if response.messages: - message = response.messages[0] - print(f"Message ID: {message.message_id}") - print(f"Status: {message.status}") - print(f"To: {message.to}") - print(f"From: {message.from_}") + if response.message_list: + for message in response.message_list.values(): + print(f"Message ID: {message.message_id}") + print(f"Status: {message.status_code}") + print(f"To: {message.to}") + print(f"From: {message.from_}") diff --git a/tests/test_messages.py b/tests/test_messages.py index ad04494..e66a825 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -24,20 +24,18 @@ def test_get_messages(self, message_service): assert isinstance(response, GetMessagesResponse) # Verify response has required fields - assert hasattr(response, "messages") - assert hasattr(response, "total_count") + assert hasattr(response, "message_list") # Print message information for verification - print(f"Total messages: {response.total_count}") - print(f"Messages in response: {len(response.messages)}") + print(f"Messages in response: {len(response.message_list)}") # If there are messages, verify the structure of the first message - if response.messages: - message = response.messages[0] - print(f"Message ID: {message.message_id}") - print(f"Status: {message.status}") - print(f"To: {message.to}") - print(f"From: {message.from_}") + if response.message_list: + for message in response.message_list.values(): + print(f"Message ID: {message.message_id}") + print(f"Status: {message.status_code}") + print(f"To: {message.to}") + print(f"From: {message.from_}") def test_get_messages_with_date_filter(self, message_service): """ @@ -67,20 +65,15 @@ def test_get_messages_with_date_filter(self, message_service): assert isinstance(response, GetMessagesResponse) # Verify response has required fields - assert hasattr(response, "messages") - assert hasattr(response, "total_count") + assert hasattr(response, "message_list") # Print message information for verification - print( - f"Total messages in date range {start_date_str} to {end_date_str}: {response.total_count}" - ) - print(f"Messages in response: {len(response.messages)}") + print(f"Messages in response: {len(response.message_list)}") # If there are messages, verify the structure of the first message - if response.messages: - message = response.messages[0] - print(f"Message ID: {message.message_id}") - print(f"Status: {message.status}") - print(f"To: {message.to}") - print(f"From: {message.from_}") - print(f"Date: {message.date_created}") + if response.message_list: + for message in response.message_list.values(): + print(f"Message ID: {message.message_id}") + print(f"Status: {message.status_code}") + print(f"To: {message.to}") + print(f"From: {message.from_}") diff --git a/tests/test_simple_send.py b/tests/test_simple_send.py index de23200..cc7fd9f 100644 --- a/tests/test_simple_send.py +++ b/tests/test_simple_send.py @@ -7,6 +7,7 @@ from solapi.model.kakao.kakao_option import KakaoOption from solapi.model.request.storage import FileTypeEnum from solapi.model.response.send_message_response import SendMessageResponse +from solapi.services.message_service import SolapiMessageService class TestSimpleSend: @@ -51,7 +52,7 @@ def test_send_sms(self, message_service, test_phone_numbers): print(f"Successful messages: {response.group_info.count.registered_success}") print(f"Failed messages: {response.group_info.count.registered_failed}") - return response.group_info.group_id + assert response.group_info.group_id is not None def test_send_mms(self, message_service, test_phone_numbers): """ @@ -113,7 +114,7 @@ def test_send_mms(self, message_service, test_phone_numbers): print(f"Successful messages: {response.group_info.count.registered_success}") print(f"Failed messages: {response.group_info.count.registered_failed}") - return response.group_info.group_id + assert response.group_info.group_id is not None def test_send_kakao_alimtalk( self, message_service, test_phone_numbers, test_kakao_options @@ -166,7 +167,7 @@ def test_send_kakao_alimtalk( ) print(f"Failed messages: {response.group_info.count.registered_failed}") - return response.group_info.group_id + assert response.group_info.group_id is not None except Exception as e: # This test may fail if Kakao template is not properly set up pytest.skip(f"Kakao Alimtalk test skipped: {str(e)}") @@ -232,7 +233,7 @@ def test_send_many(self, message_service, test_phone_numbers): print(f"To: {failed.message.to}") print(f"Error: {failed.error.message}") - return response.group_info.group_id + assert response.group_info.group_id is not None def test_send_with_reservation(self, message_service, test_phone_numbers): """ @@ -250,14 +251,11 @@ def test_send_with_reservation(self, message_service, test_phone_numbers): from_=test_phone_numbers["sender"], to=test_phone_numbers["recipient"], text="[테스트] SOLAPI Python SDK를 사용한 예약 발송 테스트입니다.", + scheduled_date=datetime.now() + timedelta(minutes=10), ) - # Create config with scheduled date (10 minutes in the future) - scheduled_date = datetime.now() + timedelta(minutes=10) - config = SendRequestConfig(scheduled_date=scheduled_date) - - # Send message with reservation - response = message_service.send(message, config) + # Send message + response = message_service.send(message) # Verify response type assert isinstance(response, SendMessageResponse) @@ -267,15 +265,50 @@ def test_send_with_reservation(self, message_service, test_phone_numbers): assert hasattr(response.group_info, "group_id") assert hasattr(response.group_info, "count") - # Verify message was scheduled successfully + # Verify message was sent successfully assert response.group_info.count.total > 0 assert response.group_info.count.registered_success > 0 # Print response information for verification print(f"Group ID: {response.group_info.group_id}") - print(f"Scheduled date: {scheduled_date}") print(f"Total messages: {response.group_info.count.total}") print(f"Successful messages: {response.group_info.count.registered_success}") print(f"Failed messages: {response.group_info.count.registered_failed}") - return response.group_info.group_id + assert response.group_info.group_id is not None + + def test_cancel_reservation( + self, message_service: SolapiMessageService, test_phone_numbers + ): + """ + Test cancelling a reserved message. + + This test verifies that the cancel_scheduled_message method works correctly. + + Args: + message_service: The SolapiMessageService fixture + test_phone_numbers: Dictionary with sender and recipient phone numbers + """ + # Create message with reservation + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트] SOLAPI Python SDK 예약 취소 테스트입니다.", + ) + request_config = SendRequestConfig( + scheduled_date=datetime.now() + timedelta(minutes=10) + ) + + # Send message and get group_id + send_response = message_service.send(message, request_config) + group_id = send_response.group_info.group_id + + # Cancel reservation + cancel_response = message_service.cancel_scheduled_message(group_id) + + # Verify cancellation response + assert cancel_response.group_id is not None + + # Print cancellation information for verification + # print(f"Cancellation status: {cancel_response.status}") + print(f"Cancelled message ID: {cancel_response.group_id}") diff --git a/tests/test_storage.py b/tests/test_storage.py index 3c8a74b..433ae20 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -47,7 +47,7 @@ def test_upload_file_mms(self, message_service): print(f"File name: {response.name}") print(f"File created: {response.date_created}") - return response.file_id + assert response.file_id is not None def test_upload_file_kakao(self, message_service): """ @@ -87,14 +87,14 @@ def test_upload_file_kakao(self, message_service): print(f"File name: {response.name}") print(f"File created: {response.date_created}") - return response.file_id + assert response.file_id is not None - def test_upload_file_document(self, message_service): + def test_upload_file_fax(self, message_service): """ - Test uploading a file as a document. + Test uploading a file as a fax. This test verifies that the upload_file method returns a valid response - when uploading a file as a document. + when uploading a file as a fax. Args: message_service: The SolapiMessageService fixture @@ -111,7 +111,7 @@ def test_upload_file_document(self, message_service): # Upload file response = message_service.upload_file( file_path=str(image_path), - upload_type=FileTypeEnum.DOCUMENT, + upload_type=FileTypeEnum.FAX, ) # Verify response type @@ -127,4 +127,4 @@ def test_upload_file_document(self, message_service): print(f"File name: {response.name}") print(f"File created: {response.date_created}") - return response.file_id + assert response.file_id is not None From f0c996ec0192d662061cb8eb90c77a4a05bc8ba7 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Sun, 11 May 2025 15:52:05 +0900 Subject: [PATCH 38/60] Add single_report response and Add temp djnago webhook example --- .../webhook/django_example/.python-version | 1 + examples/webhook/django_example/README.md | 0 examples/webhook/django_example/db.sqlite3 | Bin 0 -> 131072 bytes .../django_example/django_example/__init__.py | 0 .../django_example/app/example_controller.py | 38 ++ .../django_example/django_example/asgi.py | 16 + .../django_example/django_example/settings.py | 124 +++++++ .../django_example/django_example/urls.py | 28 ++ .../django_example/django_example/wsgi.py | 16 + examples/webhook/django_example/manage.py | 22 ++ .../webhook/django_example/pyproject.toml | 10 + examples/webhook/django_example/uv.lock | 342 ++++++++++++++++++ pyproject.toml | 3 + .../model/response/webhook/single_report.py | 110 ++++++ uv.lock | 86 +++++ 15 files changed, 796 insertions(+) create mode 100644 examples/webhook/django_example/.python-version create mode 100644 examples/webhook/django_example/README.md create mode 100644 examples/webhook/django_example/db.sqlite3 create mode 100644 examples/webhook/django_example/django_example/__init__.py create mode 100644 examples/webhook/django_example/django_example/app/example_controller.py create mode 100644 examples/webhook/django_example/django_example/asgi.py create mode 100644 examples/webhook/django_example/django_example/settings.py create mode 100644 examples/webhook/django_example/django_example/urls.py create mode 100644 examples/webhook/django_example/django_example/wsgi.py create mode 100755 examples/webhook/django_example/manage.py create mode 100644 examples/webhook/django_example/pyproject.toml create mode 100644 examples/webhook/django_example/uv.lock create mode 100644 solapi/model/response/webhook/single_report.py diff --git a/examples/webhook/django_example/.python-version b/examples/webhook/django_example/.python-version new file mode 100644 index 0000000..bd28b9c --- /dev/null +++ b/examples/webhook/django_example/.python-version @@ -0,0 +1 @@ +3.9 diff --git a/examples/webhook/django_example/README.md b/examples/webhook/django_example/README.md new file mode 100644 index 0000000..e69de29 diff --git a/examples/webhook/django_example/db.sqlite3 b/examples/webhook/django_example/db.sqlite3 new file mode 100644 index 0000000000000000000000000000000000000000..b23e22f9f1e62daaacefdbb350d8388269dbeb95 GIT binary patch literal 131072 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 Ar2qf` literal 0 HcmV?d00001 diff --git a/examples/webhook/django_example/django_example/__init__.py b/examples/webhook/django_example/django_example/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/webhook/django_example/django_example/app/example_controller.py b/examples/webhook/django_example/django_example/app/example_controller.py new file mode 100644 index 0000000..78fd57a --- /dev/null +++ b/examples/webhook/django_example/django_example/app/example_controller.py @@ -0,0 +1,38 @@ +import json + +from django.http import HttpResponseNotAllowed, JsonResponse +from django.utils.decorators import method_decorator +from django.views import View +from django.views.decorators.csrf import csrf_exempt + +from solapi.model.response.send_message_response import SendMessageResponse + + +@method_decorator(csrf_exempt, name="dispatch") +class WebhookController(View): + """ + POST 로만 받는 웹훅 엔드포인트 + CSRF 예외 처리(@csrf_exempt) 를 걸어야 외부에서 POST 요청이 들어올 때 403 에러가 나지 않습니다. + """ + + http_method_names = ["post", "options"] + + def post(self, request, *args, **kwargs): + try: + payload = json.loads(request.body.decode("utf-8")) + except json.JSONDecodeError: + return JsonResponse({"error": "invalid JSON"}, status=400) + + message_response = SendMessageResponse.model_validate(payload) + + return JsonResponse(message_response.model_dump_json(), status=200) + + def options(self, request, *args, **kwargs): + response = JsonResponse({"status": "options OK"}) + response["Access-Control-Allow-Origin"] = "*" + response["Access-Control-Allow-Methods"] = "POST, OPTIONS" + response["Access-Control-Allow-Headers"] = "Content-Type" + return response + + def http_method_not_allowed(self, request, *args, **kwargs): + return HttpResponseNotAllowed(["POST", "OPTIONS"]) diff --git a/examples/webhook/django_example/django_example/asgi.py b/examples/webhook/django_example/django_example/asgi.py new file mode 100644 index 0000000..877b9d9 --- /dev/null +++ b/examples/webhook/django_example/django_example/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for django_example project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_example.settings') + +application = get_asgi_application() diff --git a/examples/webhook/django_example/django_example/settings.py b/examples/webhook/django_example/django_example/settings.py new file mode 100644 index 0000000..fc38602 --- /dev/null +++ b/examples/webhook/django_example/django_example/settings.py @@ -0,0 +1,124 @@ +""" +Django settings for django_example project. + +Generated by 'django-admin startproject' using Django 4.2.21. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-%k)_hv-u=%1%zcwkvkmzobk1hkr55ri#^#)*hc)m66*a1_)q$v" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + +APPEND_SLASH = False + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "django_example.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "django_example.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = "ko-kr" + +TIME_ZONE = "Asia/Seoul" + +USE_I18N = True + +USE_TZ = False + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/examples/webhook/django_example/django_example/urls.py b/examples/webhook/django_example/django_example/urls.py new file mode 100644 index 0000000..9f9b5e5 --- /dev/null +++ b/examples/webhook/django_example/django_example/urls.py @@ -0,0 +1,28 @@ +""" +URL configuration for django_example project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.contrib import admin +from django.urls import path + +from .app.example_controller import ( + WebhookController, +) + +urlpatterns = [ + path("admin/", admin.site.urls), + path("webhook", WebhookController.as_view(), name="webhook"), +] diff --git a/examples/webhook/django_example/django_example/wsgi.py b/examples/webhook/django_example/django_example/wsgi.py new file mode 100644 index 0000000..d7e7411 --- /dev/null +++ b/examples/webhook/django_example/django_example/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for django_example project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_example.settings') + +application = get_wsgi_application() diff --git a/examples/webhook/django_example/manage.py b/examples/webhook/django_example/manage.py new file mode 100755 index 0000000..bc9ffd9 --- /dev/null +++ b/examples/webhook/django_example/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_example.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/examples/webhook/django_example/pyproject.toml b/examples/webhook/django_example/pyproject.toml new file mode 100644 index 0000000..9a0551a --- /dev/null +++ b/examples/webhook/django_example/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "django_example" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.9" +dependencies = [ + "django>=4.2.21", + "solapi>=5.0.0", +] diff --git a/examples/webhook/django_example/uv.lock b/examples/webhook/django_example/uv.lock new file mode 100644 index 0000000..1a303e2 --- /dev/null +++ b/examples/webhook/django_example/uv.lock @@ -0,0 +1,342 @@ +version = 1 +revision = 1 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, +] + +[[package]] +name = "asgiref" +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 }, +] + +[[package]] +name = "certifi" +version = "2025.4.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618 }, +] + +[[package]] +name = "django" +version = "4.2.21" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version < '3.10'" }, + { name = "sqlparse", marker = "python_full_version < '3.10'" }, + { name = "tzdata", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/bb/2fad5edc1af2945cb499a2e322ac28e4714fc310bd5201ed1f5a9f73a342/django-4.2.21.tar.gz", hash = "sha256:b54ac28d6aa964fc7c2f7335138a54d78980232011e0cd2231d04eed393dcb0d", size = 10424638 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/4f/aeaa3098da18b625ed672f3da6d1cd94e188d1b2cc27c2c841b2f9666282/django-4.2.21-py3-none-any.whl", hash = "sha256:1d658c7bf5d31c7d0cac1cab58bc1f822df89255080fec81909256c30e6180b3", size = 7993839 }, +] + +[[package]] +name = "django" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version >= '3.10'" }, + { name = "sqlparse", marker = "python_full_version >= '3.10'" }, + { name = "tzdata", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/10/0d546258772b8f31398e67c85e52c66ebc2b13a647193c3eef8ee433f1a8/django-5.2.1.tar.gz", hash = "sha256:57fe1f1b59462caed092c80b3dd324fd92161b620d59a9ba9181c34746c97284", size = 10818735 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/92/7448697b5838b3a1c6e1d2d6a673e908d0398e84dc4f803a2ce11e7ffc0f/django-5.2.1-py3-none-any.whl", hash = "sha256:a9b680e84f9a0e71da83e399f1e922e1ab37b2173ced046b541c72e1589a5961", size = 8301833 }, +] + +[[package]] +name = "django-example" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "django", version = "4.2.21", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "django", version = "5.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "solapi" }, +] + +[package.metadata] +requires-dist = [ + { name = "django", specifier = ">=4.2.21" }, + { name = "solapi", specifier = ">=5.0.0" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "pydantic" +version = "2.11.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/ab/5250d56ad03884ab5efd07f734203943c8a8ab40d551e208af81d0257bf2/pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d", size = 786540 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/12/46b65f3534d099349e38ef6ec98b1a5a81f42536d17e0ba382c28c67ba67/pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb", size = 443900 }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817 }, + { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357 }, + { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011 }, + { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730 }, + { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178 }, + { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462 }, + { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652 }, + { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306 }, + { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720 }, + { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915 }, + { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884 }, + { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496 }, + { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019 }, + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584 }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071 }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823 }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792 }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338 }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998 }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200 }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890 }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359 }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883 }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074 }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538 }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909 }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786 }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000 }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996 }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957 }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199 }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296 }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109 }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028 }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044 }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881 }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034 }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187 }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628 }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866 }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894 }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688 }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808 }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580 }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859 }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810 }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498 }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611 }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924 }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196 }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389 }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223 }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473 }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269 }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921 }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162 }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560 }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777 }, + { url = "https://files.pythonhosted.org/packages/53/ea/bbe9095cdd771987d13c82d104a9c8559ae9aec1e29f139e286fd2e9256e/pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d", size = 2028677 }, + { url = "https://files.pythonhosted.org/packages/49/1d/4ac5ed228078737d457a609013e8f7edc64adc37b91d619ea965758369e5/pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954", size = 1864735 }, + { url = "https://files.pythonhosted.org/packages/23/9a/2e70d6388d7cda488ae38f57bc2f7b03ee442fbcf0d75d848304ac7e405b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb", size = 1898467 }, + { url = "https://files.pythonhosted.org/packages/ff/2e/1568934feb43370c1ffb78a77f0baaa5a8b6897513e7a91051af707ffdc4/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7", size = 1983041 }, + { url = "https://files.pythonhosted.org/packages/01/1a/1a1118f38ab64eac2f6269eb8c120ab915be30e387bb561e3af904b12499/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4", size = 2136503 }, + { url = "https://files.pythonhosted.org/packages/5c/da/44754d1d7ae0f22d6d3ce6c6b1486fc07ac2c524ed8f6eca636e2e1ee49b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b", size = 2736079 }, + { url = "https://files.pythonhosted.org/packages/4d/98/f43cd89172220ec5aa86654967b22d862146bc4d736b1350b4c41e7c9c03/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3", size = 2006508 }, + { url = "https://files.pythonhosted.org/packages/2b/cc/f77e8e242171d2158309f830f7d5d07e0531b756106f36bc18712dc439df/pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a", size = 2113693 }, + { url = "https://files.pythonhosted.org/packages/54/7a/7be6a7bd43e0a47c147ba7fbf124fe8aaf1200bc587da925509641113b2d/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782", size = 2074224 }, + { url = "https://files.pythonhosted.org/packages/2a/07/31cf8fadffbb03be1cb520850e00a8490c0927ec456e8293cafda0726184/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9", size = 2245403 }, + { url = "https://files.pythonhosted.org/packages/b6/8d/bbaf4c6721b668d44f01861f297eb01c9b35f612f6b8e14173cb204e6240/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e", size = 2242331 }, + { url = "https://files.pythonhosted.org/packages/bb/93/3cc157026bca8f5006250e74515119fcaa6d6858aceee8f67ab6dc548c16/pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9", size = 1910571 }, + { url = "https://files.pythonhosted.org/packages/5b/90/7edc3b2a0d9f0dda8806c04e511a67b0b7a41d2187e2003673a996fb4310/pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3", size = 1956504 }, + { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982 }, + { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412 }, + { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749 }, + { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527 }, + { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225 }, + { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490 }, + { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525 }, + { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446 }, + { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678 }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200 }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123 }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852 }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484 }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896 }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475 }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013 }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715 }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757 }, + { url = "https://files.pythonhosted.org/packages/08/98/dbf3fdfabaf81cda5622154fda78ea9965ac467e3239078e0dcd6df159e7/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101", size = 2024034 }, + { url = "https://files.pythonhosted.org/packages/8d/99/7810aa9256e7f2ccd492590f86b79d370df1e9292f1f80b000b6a75bd2fb/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64", size = 1858578 }, + { url = "https://files.pythonhosted.org/packages/d8/60/bc06fa9027c7006cc6dd21e48dbf39076dc39d9abbaf718a1604973a9670/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d", size = 1892858 }, + { url = "https://files.pythonhosted.org/packages/f2/40/9d03997d9518816c68b4dfccb88969756b9146031b61cd37f781c74c9b6a/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535", size = 2068498 }, + { url = "https://files.pythonhosted.org/packages/d8/62/d490198d05d2d86672dc269f52579cad7261ced64c2df213d5c16e0aecb1/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d", size = 2108428 }, + { url = "https://files.pythonhosted.org/packages/9a/ec/4cd215534fd10b8549015f12ea650a1a973da20ce46430b68fc3185573e8/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6", size = 2069854 }, + { url = "https://files.pythonhosted.org/packages/1a/1a/abbd63d47e1d9b0d632fee6bb15785d0889c8a6e0a6c3b5a8e28ac1ec5d2/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca", size = 2237859 }, + { url = "https://files.pythonhosted.org/packages/80/1c/fa883643429908b1c90598fd2642af8839efd1d835b65af1f75fba4d94fe/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039", size = 2239059 }, + { url = "https://files.pythonhosted.org/packages/d4/29/3cade8a924a61f60ccfa10842f75eb12787e1440e2b8660ceffeb26685e7/pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27", size = 2066661 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "solapi" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/69/e4ffcc922aace6decf459a2a8ac1623f512375480f1e76d528a2da3f9e03/solapi-5.0.0.tar.gz", hash = "sha256:3a5581f22208f0290831749392c19dfed86ac457b33c4550c8751da0a8e584ef", size = 9921 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/22/93bc01e346843b8ae7ea983f01724a0d2e9a2c00aafd2c1b07b4da6094e1/solapi-5.0.0-py3-none-any.whl", hash = "sha256:038f9204e05a5289bce1bdf687b52a7776da880e790e86e091636b31cad46300", size = 18132 }, +] + +[[package]] +name = "sqlparse" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415 }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, +] diff --git a/pyproject.toml b/pyproject.toml index 524985e..83f3ebb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,3 +42,6 @@ build-backend = "hatchling.build" [tool.hatch.build] packages = ["solapi"] + +[tool.uv.workspace] +members = ["examples/webhook/django-example"] diff --git a/solapi/model/response/webhook/single_report.py b/solapi/model/response/webhook/single_report.py new file mode 100644 index 0000000..530a857 --- /dev/null +++ b/solapi/model/response/webhook/single_report.py @@ -0,0 +1,110 @@ +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + +from solapi.model.kakao.kakao_option import KakaoOption +from solapi.model.rcs.rcs_options import RcsOption + + +class KakaoButton(BaseModel): + button_name: Optional[str] = None + button_type: Optional[str] = None + link_mo: Optional[str] = None + link_pc: Optional[str] = None + model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) + + +class KakaoItemDetail(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) + + +class KakaoItem(BaseModel): + list_: Optional[list[Any]] = None # JSON field "list" + summary: Optional[KakaoItemDetail] = None + model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) + + +class KakaoHighlight(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + image_id: Optional[str] = None + model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) + + +class NaverOption(BaseModel): + talk_id: Optional[str] = None + template_id: Optional[str] = None + disable_sms: Optional[bool] = None + buttons: Optional[list[Any]] = None + model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) + + +class ReportData(BaseModel): + message_id: Optional[str] = Field( + default=None, validation_alias="messageId", serialization_alias="messageId" + ) + group_id: Optional[str] = Field( + default=None, validation_alias="groupId", serialization_alias="groupId" + ) + type: Optional[str] = None + to: Optional[str] = None + from_: Optional[str] = Field( + default=None, validation_alias="from", serialization_alias="from" + ) + status_code: Optional[str] = Field( + default=None, validation_alias="statusCode", serialization_alias="statusCode" + ) + date_processed: Optional[str] = Field( + default=None, + validation_alias="dateProcessed", + serialization_alias="dateProcessed", + ) + date_reported: Optional[str] = Field( + default=None, + validation_alias="dateReported", + serialization_alias="dateReported", + ) + date_received: Optional[str] = Field( + default=None, + validation_alias="dateReceived", + serialization_alias="dateReceived", + ) + network_code: Optional[str] = Field( + default=None, validation_alias="networkCode", serialization_alias="networkCode" + ) + kakao_options: Optional[KakaoOption] = Field( + default=None, + validation_alias="kakaoOptions", + serialization_alias="kakaoOptions", + ) + rcs_options: Optional[RcsOption] = Field( + default=None, validation_alias="rcsOptions", serialization_alias="rcsOptions" + ) + naver_options: Optional[NaverOption] = Field( + default=None, + validation_alias="naverOptions", + serialization_alias="naverOptions", + ) + custom_fields: Optional[dict] = Field( + default={}, validation_alias="customFields", serialization_alias="customFields" + ) + status_message: Optional[str] = Field( + default=None, + validation_alias="statusMessage", + serialization_alias="statusMessage", + ) + + model_config = ConfigDict(populate_by_name=True, extra="ignore") + + +class SingleReport(BaseModel): + event_data_id: Optional[str] = None + data: Optional[ReportData] = None + retry_count: Optional[int] = None + + model_config = ConfigDict( + populate_by_name=True, alias_generator=to_camel, extra="ignore" + ) diff --git a/uv.lock b/uv.lock index ba3ca9a..4a33671 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,16 @@ version = 1 revision = 1 requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] + +[manifest] +members = [ + "django-example", + "solapi", +] [[package]] name = "annotated-types" @@ -26,6 +36,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, ] +[[package]] +name = "asgiref" +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 }, +] + [[package]] name = "certifi" version = "2025.1.31" @@ -44,6 +66,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "django" +version = "4.2.21" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version < '3.10'" }, + { name = "sqlparse", marker = "python_full_version < '3.10'" }, + { name = "tzdata", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/bb/2fad5edc1af2945cb499a2e322ac28e4714fc310bd5201ed1f5a9f73a342/django-4.2.21.tar.gz", hash = "sha256:b54ac28d6aa964fc7c2f7335138a54d78980232011e0cd2231d04eed393dcb0d", size = 10424638 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/4f/aeaa3098da18b625ed672f3da6d1cd94e188d1b2cc27c2c841b2f9666282/django-4.2.21-py3-none-any.whl", hash = "sha256:1d658c7bf5d31c7d0cac1cab58bc1f822df89255080fec81909256c30e6180b3", size = 7993839 }, +] + +[[package]] +name = "django" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version >= '3.10'" }, + { name = "sqlparse", marker = "python_full_version >= '3.10'" }, + { name = "tzdata", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/10/0d546258772b8f31398e67c85e52c66ebc2b13a647193c3eef8ee433f1a8/django-5.2.1.tar.gz", hash = "sha256:57fe1f1b59462caed092c80b3dd324fd92161b620d59a9ba9181c34746c97284", size = 10818735 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/92/7448697b5838b3a1c6e1d2d6a673e908d0398e84dc4f803a2ce11e7ffc0f/django-5.2.1-py3-none-any.whl", hash = "sha256:a9b680e84f9a0e71da83e399f1e922e1ab37b2173ced046b541c72e1589a5961", size = 8301833 }, +] + +[[package]] +name = "django-example" +version = "0.1.0" +source = { virtual = "examples/webhook/django-example" } +dependencies = [ + { name = "django", version = "4.2.21", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "django", version = "5.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + +[package.metadata] +requires-dist = [{ name = "django", specifier = ">=4.2.21" }] + [[package]] name = "exceptiongroup" version = "1.2.2" @@ -325,6 +393,15 @@ requires-dist = [ ] provides-extras = ["dev"] +[[package]] +name = "sqlparse" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415 }, +] + [[package]] name = "tomli" version = "2.2.1" @@ -384,3 +461,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846 wheels = [ { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 }, ] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, +] From 67d9f58b55f5432fb566802164ab9cd3f22df20f Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 12 May 2025 09:09:12 +0900 Subject: [PATCH 39/60] Remove uv workspace --- pyproject.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 83f3ebb..524985e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,3 @@ build-backend = "hatchling.build" [tool.hatch.build] packages = ["solapi"] - -[tool.uv.workspace] -members = ["examples/webhook/django-example"] From 721ec06be7d8e58bb4a01482ecf5b2be4a46e91b Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 12 May 2025 11:05:29 +0900 Subject: [PATCH 40/60] =?UTF-8?q?=EC=9B=B9=ED=9B=85=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?Django=20=EC=98=88=EC=A0=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 그룹 리포트와 단일 리포트 웹훅 컨트롤러 추가 - Django URL 패턴 수정: 새로운 웹훅 경로 추가 - pyproject.toml 및 uv.lock 파일 수정: 의존성 및 작업 공간 설정 업데이트 - 모델 및 응답 형식 개선: Pydantic 모델에 선택적 필드 추가 --- ...ntroller.py => group_report_controller.py} | 20 +- .../app/single_report_controller.py | 48 ++++ .../django_example/django_example/urls.py | 10 +- .../webhook/django_example/pyproject.toml | 13 +- examples/webhook/django_example/uv.lock | 252 +----------------- pyproject.toml | 6 + solapi/model/naver/naver_button.py | 15 ++ solapi/model/naver/naver_option.py | 16 ++ solapi/model/response/common_response.py | 62 ++--- solapi/model/webhook/group_report.py | 20 ++ .../{response => }/webhook/single_report.py | 50 +--- uv.lock | 8 +- 12 files changed, 185 insertions(+), 335 deletions(-) rename examples/webhook/django_example/django_example/app/{example_controller.py => group_report_controller.py} (63%) create mode 100644 examples/webhook/django_example/django_example/app/single_report_controller.py create mode 100644 solapi/model/naver/naver_button.py create mode 100644 solapi/model/naver/naver_option.py create mode 100644 solapi/model/webhook/group_report.py rename solapi/model/{response => }/webhook/single_report.py (63%) diff --git a/examples/webhook/django_example/django_example/app/example_controller.py b/examples/webhook/django_example/django_example/app/group_report_controller.py similarity index 63% rename from examples/webhook/django_example/django_example/app/example_controller.py rename to examples/webhook/django_example/django_example/app/group_report_controller.py index 78fd57a..2061467 100644 --- a/examples/webhook/django_example/django_example/app/example_controller.py +++ b/examples/webhook/django_example/django_example/app/group_report_controller.py @@ -5,13 +5,13 @@ from django.views import View from django.views.decorators.csrf import csrf_exempt -from solapi.model.response.send_message_response import SendMessageResponse +from solapi.model.webhook.group_report import GroupReportPayload @method_decorator(csrf_exempt, name="dispatch") -class WebhookController(View): +class GroupReportWebhookController(View): """ - POST 로만 받는 웹훅 엔드포인트 + POST 로만 받는 그룹 리포트(Group Report) 웹훅 엔드포인트 CSRF 예외 처리(@csrf_exempt) 를 걸어야 외부에서 POST 요청이 들어올 때 403 에러가 나지 않습니다. """ @@ -23,9 +23,19 @@ def post(self, request, *args, **kwargs): except json.JSONDecodeError: return JsonResponse({"error": "invalid JSON"}, status=400) - message_response = SendMessageResponse.model_validate(payload) + message_response = GroupReportPayload.model_validate(payload) + group_report = message_response.root[0] - return JsonResponse(message_response.model_dump_json(), status=200) + # 이후 필요한 프로그래밍 처리.. 아래 방식처럼 데이터를 추출해서 사용할 수 있습니다. + print(group_report.data.group_id) + + # 혹은.. + print(group_report.data.date_created) + + # 또는.. + print(group_report.data.log) + + return JsonResponse(group_report.model_dump(), status=200) def options(self, request, *args, **kwargs): response = JsonResponse({"status": "options OK"}) diff --git a/examples/webhook/django_example/django_example/app/single_report_controller.py b/examples/webhook/django_example/django_example/app/single_report_controller.py new file mode 100644 index 0000000..c9444e3 --- /dev/null +++ b/examples/webhook/django_example/django_example/app/single_report_controller.py @@ -0,0 +1,48 @@ +import json + +from django.http import HttpResponseNotAllowed, JsonResponse +from django.utils.decorators import method_decorator +from django.views import View +from django.views.decorators.csrf import csrf_exempt + +from solapi.model.webhook.single_report import SingleReportPayload + + +@method_decorator(csrf_exempt, name="dispatch") +class SingleReportWebhookController(View): + """ + POST 로만 받는 메시지 리포트(Single Report) 웹훅 엔드포인트 + CSRF 예외 처리(@csrf_exempt) 를 걸어야 외부에서 POST 요청이 들어올 때 403 에러가 나지 않습니다. + """ + + http_method_names = ["post", "options"] + + def post(self, request, *args, **kwargs): + try: + payload = json.loads(request.body.decode("utf-8")) + except json.JSONDecodeError: + return JsonResponse({"error": "invalid JSON"}, status=400) + + message_response = SingleReportPayload.model_validate(payload) + single_report = message_response.root[0] + + # 이후 필요한 프로그래밍 처리.. 아래 방식처럼 데이터를 추출해서 사용할 수 있습니다. + print(single_report.data.kakao_options.pf_id) + + # 혹은.. + print(single_report.data.message_id) + + # 또는.. + print(single_report.data.naver_options) + + return JsonResponse(single_report.model_dump(), status=200) + + def options(self, request, *args, **kwargs): + response = JsonResponse({"status": "options OK"}) + response["Access-Control-Allow-Origin"] = "*" + response["Access-Control-Allow-Methods"] = "POST, OPTIONS" + response["Access-Control-Allow-Headers"] = "Content-Type" + return response + + def http_method_not_allowed(self, request, *args, **kwargs): + return HttpResponseNotAllowed(["POST", "OPTIONS"]) diff --git a/examples/webhook/django_example/django_example/urls.py b/examples/webhook/django_example/django_example/urls.py index 9f9b5e5..ad11ff2 100644 --- a/examples/webhook/django_example/django_example/urls.py +++ b/examples/webhook/django_example/django_example/urls.py @@ -18,11 +18,15 @@ from django.contrib import admin from django.urls import path -from .app.example_controller import ( - WebhookController, +from .app.group_report_controller import ( + GroupReportWebhookController, +) +from .app.single_report_controller import ( + SingleReportWebhookController, ) urlpatterns = [ path("admin/", admin.site.urls), - path("webhook", WebhookController.as_view(), name="webhook"), + path("webhook/single-report", SingleReportWebhookController.as_view()), + path("webhook/group-report", GroupReportWebhookController.as_view()), ] diff --git a/examples/webhook/django_example/pyproject.toml b/examples/webhook/django_example/pyproject.toml index 9a0551a..30ed6aa 100644 --- a/examples/webhook/django_example/pyproject.toml +++ b/examples/webhook/django_example/pyproject.toml @@ -1,10 +1,17 @@ [project] name = "django_example" version = "0.1.0" -description = "Add your description here" +description = "Django example for SOLAPI webhook" readme = "README.md" requires-python = ">=3.9" dependencies = [ - "django>=4.2.21", - "solapi>=5.0.0", + "django>=4.2.0", + "solapi" ] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/examples/webhook/django_example/uv.lock b/examples/webhook/django_example/uv.lock index 1a303e2..f375cb4 100644 --- a/examples/webhook/django_example/uv.lock +++ b/examples/webhook/django_example/uv.lock @@ -6,30 +6,6 @@ resolution-markers = [ "python_full_version < '3.10'", ] -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, -] - -[[package]] -name = "anyio" -version = "4.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, -] - [[package]] name = "asgiref" version = "3.8.1" @@ -42,15 +18,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 }, ] -[[package]] -name = "certifi" -version = "2025.4.26" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618 }, -] - [[package]] name = "django" version = "4.2.21" @@ -92,215 +59,10 @@ source = { virtual = "." } dependencies = [ { name = "django", version = "4.2.21", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "django", version = "5.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "solapi" }, ] [package.metadata] -requires-dist = [ - { name = "django", specifier = ">=4.2.21" }, - { name = "solapi", specifier = ">=5.0.0" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, -] - -[[package]] -name = "idna" -version = "3.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, -] - -[[package]] -name = "pydantic" -version = "2.11.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/77/ab/5250d56ad03884ab5efd07f734203943c8a8ab40d551e208af81d0257bf2/pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d", size = 786540 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/12/46b65f3534d099349e38ef6ec98b1a5a81f42536d17e0ba382c28c67ba67/pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb", size = 443900 }, -] - -[[package]] -name = "pydantic-core" -version = "2.33.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817 }, - { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357 }, - { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011 }, - { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730 }, - { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178 }, - { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462 }, - { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652 }, - { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306 }, - { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720 }, - { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915 }, - { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884 }, - { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496 }, - { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019 }, - { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584 }, - { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071 }, - { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823 }, - { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792 }, - { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338 }, - { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998 }, - { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200 }, - { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890 }, - { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359 }, - { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883 }, - { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074 }, - { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538 }, - { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909 }, - { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786 }, - { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000 }, - { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996 }, - { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957 }, - { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199 }, - { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296 }, - { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109 }, - { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028 }, - { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044 }, - { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881 }, - { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034 }, - { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187 }, - { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628 }, - { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866 }, - { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894 }, - { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688 }, - { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808 }, - { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580 }, - { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859 }, - { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810 }, - { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498 }, - { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611 }, - { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924 }, - { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196 }, - { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389 }, - { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223 }, - { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473 }, - { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269 }, - { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921 }, - { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162 }, - { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560 }, - { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777 }, - { url = "https://files.pythonhosted.org/packages/53/ea/bbe9095cdd771987d13c82d104a9c8559ae9aec1e29f139e286fd2e9256e/pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d", size = 2028677 }, - { url = "https://files.pythonhosted.org/packages/49/1d/4ac5ed228078737d457a609013e8f7edc64adc37b91d619ea965758369e5/pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954", size = 1864735 }, - { url = "https://files.pythonhosted.org/packages/23/9a/2e70d6388d7cda488ae38f57bc2f7b03ee442fbcf0d75d848304ac7e405b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb", size = 1898467 }, - { url = "https://files.pythonhosted.org/packages/ff/2e/1568934feb43370c1ffb78a77f0baaa5a8b6897513e7a91051af707ffdc4/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7", size = 1983041 }, - { url = "https://files.pythonhosted.org/packages/01/1a/1a1118f38ab64eac2f6269eb8c120ab915be30e387bb561e3af904b12499/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4", size = 2136503 }, - { url = "https://files.pythonhosted.org/packages/5c/da/44754d1d7ae0f22d6d3ce6c6b1486fc07ac2c524ed8f6eca636e2e1ee49b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b", size = 2736079 }, - { url = "https://files.pythonhosted.org/packages/4d/98/f43cd89172220ec5aa86654967b22d862146bc4d736b1350b4c41e7c9c03/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3", size = 2006508 }, - { url = "https://files.pythonhosted.org/packages/2b/cc/f77e8e242171d2158309f830f7d5d07e0531b756106f36bc18712dc439df/pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a", size = 2113693 }, - { url = "https://files.pythonhosted.org/packages/54/7a/7be6a7bd43e0a47c147ba7fbf124fe8aaf1200bc587da925509641113b2d/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782", size = 2074224 }, - { url = "https://files.pythonhosted.org/packages/2a/07/31cf8fadffbb03be1cb520850e00a8490c0927ec456e8293cafda0726184/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9", size = 2245403 }, - { url = "https://files.pythonhosted.org/packages/b6/8d/bbaf4c6721b668d44f01861f297eb01c9b35f612f6b8e14173cb204e6240/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e", size = 2242331 }, - { url = "https://files.pythonhosted.org/packages/bb/93/3cc157026bca8f5006250e74515119fcaa6d6858aceee8f67ab6dc548c16/pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9", size = 1910571 }, - { url = "https://files.pythonhosted.org/packages/5b/90/7edc3b2a0d9f0dda8806c04e511a67b0b7a41d2187e2003673a996fb4310/pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3", size = 1956504 }, - { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982 }, - { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412 }, - { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749 }, - { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527 }, - { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225 }, - { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490 }, - { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525 }, - { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446 }, - { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678 }, - { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200 }, - { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123 }, - { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852 }, - { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484 }, - { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896 }, - { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475 }, - { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013 }, - { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715 }, - { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757 }, - { url = "https://files.pythonhosted.org/packages/08/98/dbf3fdfabaf81cda5622154fda78ea9965ac467e3239078e0dcd6df159e7/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101", size = 2024034 }, - { url = "https://files.pythonhosted.org/packages/8d/99/7810aa9256e7f2ccd492590f86b79d370df1e9292f1f80b000b6a75bd2fb/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64", size = 1858578 }, - { url = "https://files.pythonhosted.org/packages/d8/60/bc06fa9027c7006cc6dd21e48dbf39076dc39d9abbaf718a1604973a9670/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d", size = 1892858 }, - { url = "https://files.pythonhosted.org/packages/f2/40/9d03997d9518816c68b4dfccb88969756b9146031b61cd37f781c74c9b6a/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535", size = 2068498 }, - { url = "https://files.pythonhosted.org/packages/d8/62/d490198d05d2d86672dc269f52579cad7261ced64c2df213d5c16e0aecb1/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d", size = 2108428 }, - { url = "https://files.pythonhosted.org/packages/9a/ec/4cd215534fd10b8549015f12ea650a1a973da20ce46430b68fc3185573e8/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6", size = 2069854 }, - { url = "https://files.pythonhosted.org/packages/1a/1a/abbd63d47e1d9b0d632fee6bb15785d0889c8a6e0a6c3b5a8e28ac1ec5d2/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca", size = 2237859 }, - { url = "https://files.pythonhosted.org/packages/80/1c/fa883643429908b1c90598fd2642af8839efd1d835b65af1f75fba4d94fe/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039", size = 2239059 }, - { url = "https://files.pythonhosted.org/packages/d4/29/3cade8a924a61f60ccfa10842f75eb12787e1440e2b8660ceffeb26685e7/pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27", size = 2066661 }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, -] - -[[package]] -name = "solapi" -version = "5.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpx" }, - { name = "pydantic" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/23/69/e4ffcc922aace6decf459a2a8ac1623f512375480f1e76d528a2da3f9e03/solapi-5.0.0.tar.gz", hash = "sha256:3a5581f22208f0290831749392c19dfed86ac457b33c4550c8751da0a8e584ef", size = 9921 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/22/93bc01e346843b8ae7ea983f01724a0d2e9a2c00aafd2c1b07b4da6094e1/solapi-5.0.0-py3-none-any.whl", hash = "sha256:038f9204e05a5289bce1bdf687b52a7776da880e790e86e091636b31cad46300", size = 18132 }, -] +requires-dist = [{ name = "django", specifier = ">=4.2.21" }] [[package]] name = "sqlparse" @@ -320,18 +82,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, ] -[[package]] -name = "typing-inspection" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 }, -] - [[package]] name = "tzdata" version = "2025.2" diff --git a/pyproject.toml b/pyproject.toml index 524985e..135faae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,3 +42,9 @@ build-backend = "hatchling.build" [tool.hatch.build] packages = ["solapi"] + +[tool.uv.workspace] +members = ["examples/webhook/django_example"] + +[tool.uv.sources] +solapi = { workspace = true } diff --git a/solapi/model/naver/naver_button.py b/solapi/model/naver/naver_button.py new file mode 100644 index 0000000..f020783 --- /dev/null +++ b/solapi/model/naver/naver_button.py @@ -0,0 +1,15 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class NaverButton(BaseModel): + button_name: Optional[str] = None + button_type: Optional[str] = None + link_mo: Optional[str] = None + link_pc: Optional[str] = None + link_and: Optional[str] = None + link_ios: Optional[str] = None + + model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) diff --git a/solapi/model/naver/naver_option.py b/solapi/model/naver/naver_option.py new file mode 100644 index 0000000..8de9ee4 --- /dev/null +++ b/solapi/model/naver/naver_option.py @@ -0,0 +1,16 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + +from solapi.model.naver.naver_button import NaverButton + + +class NaverOption(BaseModel): + talk_id: Optional[str] = None + template_id: Optional[str] = None + disable_sms: Optional[bool] = None + variables: Optional[dict[str, str]] = None + buttons: Optional[list[NaverButton]] = None + + model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) diff --git a/solapi/model/response/common_response.py b/solapi/model/response/common_response.py index a99ce29..162a88f 100644 --- a/solapi/model/response/common_response.py +++ b/solapi/model/response/common_response.py @@ -30,47 +30,47 @@ class CommonCashResponse(BaseModel): class CommonPriceTypeResponse(BaseModel): - sms: dict[str, float] - lms: dict[str, float] - mms: dict[str, float] - ata: dict[str, float] - cta: dict[str, float] - cti: dict[str, float] - nsa: dict[str, float] - rcs_sms: dict[str, float] - rcs_lms: dict[str, float] - rcs_mms: dict[str, float] - rcs_tpl: dict[str, float] - rcs_itpl: dict[str, float] - rcs_ltpl: dict[str, float] - fax: dict[str, float] - voice: dict[str, float] + sms: Optional[dict[str, float]] = None + lms: Optional[dict[str, float]] = None + mms: Optional[dict[str, float]] = None + ata: Optional[dict[str, float]] = None + cta: Optional[dict[str, float]] = None + cti: Optional[dict[str, float]] = None + nsa: Optional[dict[str, float]] = None + rcs_sms: Optional[dict[str, float]] = None + rcs_lms: Optional[dict[str, float]] = None + rcs_mms: Optional[dict[str, float]] = None + rcs_tpl: Optional[dict[str, float]] = None + rcs_itpl: Optional[dict[str, float]] = None + rcs_ltpl: Optional[dict[str, float]] = None + fax: Optional[dict[str, float]] = None + voice: Optional[dict[str, float]] = None model_config = ConfigDict(extra="ignore") class EachTypePriceResponse(BaseModel): - sms: float - lms: float - mms: float - ata: float - cta: float - cti: float - nsa: float - rcs_sms: float - rcs_lms: float - rcs_mms: float - rcs_tpl: float - rcs_itpl: float - rcs_ltpl: float - fax: float - voice: float + sms: Optional[float] = None + lms: Optional[float] = None + mms: Optional[float] = None + ata: Optional[float] = None + cta: Optional[float] = None + cti: Optional[float] = None + nsa: Optional[float] = None + rcs_sms: Optional[float] = None + rcs_lms: Optional[float] = None + rcs_mms: Optional[float] = None + rcs_tpl: Optional[float] = None + rcs_itpl: Optional[float] = None + rcs_ltpl: Optional[float] = None + fax: Optional[float] = None + voice: Optional[float] = None model_config = ConfigDict(extra="ignore") class AppResponse(BaseModel): - profit: EachTypePriceResponse + profit: Optional[EachTypePriceResponse] = None app_id: Optional[str] = None model_config = ConfigDict( diff --git a/solapi/model/webhook/group_report.py b/solapi/model/webhook/group_report.py new file mode 100644 index 0000000..86b697c --- /dev/null +++ b/solapi/model/webhook/group_report.py @@ -0,0 +1,20 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict, RootModel +from pydantic.alias_generators import to_camel + +from solapi.model.response.common_response import GroupMessageResponse + + +class GroupReport(BaseModel): + event_data_id: Optional[str] = None + data: Optional[GroupMessageResponse] = None + retry_count: Optional[int] = None + + model_config = ConfigDict( + populate_by_name=True, alias_generator=to_camel, extra="ignore" + ) + + +class GroupReportPayload(RootModel[list[GroupReport]]): + pass diff --git a/solapi/model/response/webhook/single_report.py b/solapi/model/webhook/single_report.py similarity index 63% rename from solapi/model/response/webhook/single_report.py rename to solapi/model/webhook/single_report.py index 530a857..4356abd 100644 --- a/solapi/model/response/webhook/single_report.py +++ b/solapi/model/webhook/single_report.py @@ -1,48 +1,14 @@ -from typing import Any, Optional +from typing import Optional -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, RootModel from pydantic.alias_generators import to_camel from solapi.model.kakao.kakao_option import KakaoOption +from solapi.model.naver.naver_option import NaverOption from solapi.model.rcs.rcs_options import RcsOption -class KakaoButton(BaseModel): - button_name: Optional[str] = None - button_type: Optional[str] = None - link_mo: Optional[str] = None - link_pc: Optional[str] = None - model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) - - -class KakaoItemDetail(BaseModel): - title: Optional[str] = None - description: Optional[str] = None - model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) - - -class KakaoItem(BaseModel): - list_: Optional[list[Any]] = None # JSON field "list" - summary: Optional[KakaoItemDetail] = None - model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) - - -class KakaoHighlight(BaseModel): - title: Optional[str] = None - description: Optional[str] = None - image_id: Optional[str] = None - model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) - - -class NaverOption(BaseModel): - talk_id: Optional[str] = None - template_id: Optional[str] = None - disable_sms: Optional[bool] = None - buttons: Optional[list[Any]] = None - model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel) - - -class ReportData(BaseModel): +class SingleReportData(BaseModel): message_id: Optional[str] = Field( default=None, validation_alias="messageId", serialization_alias="messageId" ) @@ -101,10 +67,14 @@ class ReportData(BaseModel): class SingleReport(BaseModel): - event_data_id: Optional[str] = None - data: Optional[ReportData] = None + event_data_id: str = None + data: SingleReportData retry_count: Optional[int] = None model_config = ConfigDict( populate_by_name=True, alias_generator=to_camel, extra="ignore" ) + + +class SingleReportPayload(RootModel[list[SingleReport]]): + pass diff --git a/uv.lock b/uv.lock index 4a33671..4d60c0a 100644 --- a/uv.lock +++ b/uv.lock @@ -103,14 +103,18 @@ wheels = [ [[package]] name = "django-example" version = "0.1.0" -source = { virtual = "examples/webhook/django-example" } +source = { editable = "examples/webhook/django_example" } dependencies = [ { name = "django", version = "4.2.21", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "django", version = "5.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "solapi" }, ] [package.metadata] -requires-dist = [{ name = "django", specifier = ">=4.2.21" }] +requires-dist = [ + { name = "django", specifier = ">=4.2.0" }, + { name = "solapi", editable = "." }, +] [[package]] name = "exceptiongroup" From 32034a0cfc0edd01222296d05fb786fdf342ba4a Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 12 May 2025 11:27:20 +0900 Subject: [PATCH 41/60] =?UTF-8?q?django=5Fexample=EC=9D=98=20pyproject.tom?= =?UTF-8?q?l=20=ED=8C=8C=EC=9D=BC=EC=97=90=EC=84=9C=20solapi=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=EC=9D=84=205.0.1=EB=A1=9C=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=ED=95=98=EA=B3=A0,=20README.md=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=98?= =?UTF-8?q?=EC=97=AC=20SOLAPI=20Django=20=EC=9B=B9=ED=9B=85=20=EC=98=88?= =?UTF-8?q?=EC=A0=9C=EC=97=90=20=EB=8C=80=ED=95=9C=20=EC=84=A4=EB=AA=85?= =?UTF-8?q?=EC=9D=84=20=ED=8F=AC=ED=95=A8=ED=96=88=EC=8A=B5=EB=8B=88?= =?UTF-8?q?=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/webhook/django_example/README.md | 20 +++++++++++++++++++ .../webhook/django_example/pyproject.toml | 6 +++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/examples/webhook/django_example/README.md b/examples/webhook/django_example/README.md index e69de29..089f706 100644 --- a/examples/webhook/django_example/README.md +++ b/examples/webhook/django_example/README.md @@ -0,0 +1,20 @@ +# SOLAPI Django Webhook 예제 + +- 해당 예제는 Django에서 SOLAPI에서 제공하는 webhook을 통해 파라미터를 수신할 때 solapi 모듈을 이용하여 메시지 리포트, 그룹 리포트 데이터를 받아오는 예제입니다. +- 해당 예제는 uv를 통해 패키지를 관리하고 있습니다. 따라서 사전에 [uv](https://docs.astral.sh/uv/getting-started/installation/)가 설치되어 있어야합니다. + +## Getting Started + +예제를 다운로드 받은 직후, 아래의 명령어를 Django 프로젝트 경로에서 입력해주세요. + +```bash +uv sync +``` + +그다음 Django 서버를 실행하려면 아래의 명령어를 실행해주세요. + +```bash +uv run manage.py runserver +``` + +이후 `POST http://localhost:8080/webhook/single-report` 혹은 `POST http://localhost:8080/webhook/group-report`를 통해 웹훅 데이터를 수신받아서 테스트해볼 수 있습니다. \ No newline at end of file diff --git a/examples/webhook/django_example/pyproject.toml b/examples/webhook/django_example/pyproject.toml index 30ed6aa..926aff4 100644 --- a/examples/webhook/django_example/pyproject.toml +++ b/examples/webhook/django_example/pyproject.toml @@ -6,12 +6,12 @@ readme = "README.md" requires-python = ">=3.9" dependencies = [ "django>=4.2.0", - "solapi" + "solapi>=5.0.1" ] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" -[tool.hatch.metadata] -allow-direct-references = true +# [tool.hatch.metadata] +# allow-direct-references = true From 91d6e13159ff7068349e48713883479863d6e1ec Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 12 May 2025 11:28:22 +0900 Subject: [PATCH 42/60] Format files --- examples/webhook/django_example/django_example/asgi.py | 2 +- examples/webhook/django_example/django_example/wsgi.py | 2 +- examples/webhook/django_example/manage.py | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/examples/webhook/django_example/django_example/asgi.py b/examples/webhook/django_example/django_example/asgi.py index 877b9d9..ceb53aa 100644 --- a/examples/webhook/django_example/django_example/asgi.py +++ b/examples/webhook/django_example/django_example/asgi.py @@ -11,6 +11,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_example.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_example.settings") application = get_asgi_application() diff --git a/examples/webhook/django_example/django_example/wsgi.py b/examples/webhook/django_example/django_example/wsgi.py index d7e7411..a6b3d52 100644 --- a/examples/webhook/django_example/django_example/wsgi.py +++ b/examples/webhook/django_example/django_example/wsgi.py @@ -11,6 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_example.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_example.settings") application = get_wsgi_application() diff --git a/examples/webhook/django_example/manage.py b/examples/webhook/django_example/manage.py index bc9ffd9..b3f0b0f 100755 --- a/examples/webhook/django_example/manage.py +++ b/examples/webhook/django_example/manage.py @@ -1,12 +1,13 @@ #!/usr/bin/env python """Django's command-line utility for administrative tasks.""" + import os import sys def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_example.settings') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_example.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -18,5 +19,5 @@ def main(): execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() From f5b98f6c5553f1f686fa412c8a6b003f89a4cd06 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 12 May 2025 11:31:14 +0900 Subject: [PATCH 43/60] Update README.md --- examples/webhook/django_example/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/webhook/django_example/README.md b/examples/webhook/django_example/README.md index 089f706..5b2ca9c 100644 --- a/examples/webhook/django_example/README.md +++ b/examples/webhook/django_example/README.md @@ -17,4 +17,4 @@ uv sync uv run manage.py runserver ``` -이후 `POST http://localhost:8080/webhook/single-report` 혹은 `POST http://localhost:8080/webhook/group-report`를 통해 웹훅 데이터를 수신받아서 테스트해볼 수 있습니다. \ No newline at end of file +이후에는 `POST http://localhost:8080/webhook/single-report` 또는 `POST http://localhost:8080/webhook/group-report`로 웹훅 데이터를 수신하여 테스트해 볼 수 있습니다. \ No newline at end of file From 0a00996cab3b33a7707956613e4c15267df1d168 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 12 May 2025 15:45:18 +0900 Subject: [PATCH 44/60] Update single_report_controller.py --- .../django_example/app/single_report_controller.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/webhook/django_example/django_example/app/single_report_controller.py b/examples/webhook/django_example/django_example/app/single_report_controller.py index c9444e3..8da5419 100644 --- a/examples/webhook/django_example/django_example/app/single_report_controller.py +++ b/examples/webhook/django_example/django_example/app/single_report_controller.py @@ -35,6 +35,7 @@ def post(self, request, *args, **kwargs): # 또는.. print(single_report.data.naver_options) + # 200을 리턴해야 합니다. (200이 리턴되지 않으면 특정 시간 간격을 두고 총 5번이 호출됩니다) return JsonResponse(single_report.model_dump(), status=200) def options(self, request, *args, **kwargs): From aa9bc34a1f9818c36708a2808992133d2e5f007e Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 12 May 2025 15:45:31 +0900 Subject: [PATCH 45/60] Update group_report_controller.py --- .../django_example/django_example/app/group_report_controller.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/webhook/django_example/django_example/app/group_report_controller.py b/examples/webhook/django_example/django_example/app/group_report_controller.py index 2061467..854ec5a 100644 --- a/examples/webhook/django_example/django_example/app/group_report_controller.py +++ b/examples/webhook/django_example/django_example/app/group_report_controller.py @@ -35,6 +35,7 @@ def post(self, request, *args, **kwargs): # 또는.. print(group_report.data.log) + # 200을 리턴해야 합니다. (200이 리턴되지 않으면 특정 시간 간격을 두고 총 5번이 호출됩니다) return JsonResponse(group_report.model_dump(), status=200) def options(self, request, *args, **kwargs): From ff4f0740f834d9b0d785457966c3b219937c4258 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 14 Jul 2025 12:55:38 +0900 Subject: [PATCH 46/60] =?UTF-8?q?fetcher.py=EC=97=90=EC=84=9C=20HTTPTransp?= =?UTF-8?q?ort=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80,?= =?UTF-8?q?=20string=5Fdate=5Ftransfer.py=EC=97=90=EC=84=9C=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EB=8C=80=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0,?= =?UTF-8?q?=20kakao=5Foption.py=EC=97=90=20Bms=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20message.py=EC=97=90=EC=84=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=94=EB=B2=88=ED=98=B8=20=EC=A0=95=EA=B7=9C=ED=99=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80,=20voice=5Foption.py=20?= =?UTF-8?q?=EB=B0=8F=20bms.py=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80,?= =?UTF-8?q?=20GetGroupsCriteriaType=20=EC=9D=B4=EB=A6=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EB=B2=84=EC=A0=84=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- solapi/lib/fetcher.py | 10 +++++++-- solapi/lib/string_date_transfer.py | 9 +++++++- solapi/model/__init__.py | 2 ++ solapi/model/kakao/kakao_option.py | 15 +++++++++++-- solapi/model/request/__init__.py | 2 +- solapi/model/request/groups/get_groups.py | 2 +- solapi/model/request/kakao/bms.py | 12 ++++++++++ solapi/model/request/kakao/kakao_option.py | 2 ++ solapi/model/request/message.py | 26 +++++++++++++++++++++- solapi/model/request/voice/voice_option.py | 18 +++++++++++++++ solapi/services/message_service.py | 4 ++-- 11 files changed, 92 insertions(+), 10 deletions(-) create mode 100644 solapi/model/request/kakao/bms.py create mode 100644 solapi/model/request/voice/voice_option.py diff --git a/solapi/lib/fetcher.py b/solapi/lib/fetcher.py index 85b3002..6b74048 100644 --- a/solapi/lib/fetcher.py +++ b/solapi/lib/fetcher.py @@ -39,11 +39,17 @@ def default_fetcher( headers = { "Authorization": authorization_header_data, "Content-Type": "application/json", + "Connection": "keep-alive", } - with httpx.Client() as client: + transport = httpx.HTTPTransport(retries=3) + + with httpx.Client(transport=transport) as client: response: Response = client.request( - method=request["method"], url=request["url"], headers=headers, json=data + method=request["method"], + url=request["url"], + headers=headers, + json=data, ) # 4xx 에러 처리: 클라이언트 오류일 경우 diff --git a/solapi/lib/string_date_transfer.py b/solapi/lib/string_date_transfer.py index 8b1c257..ea27406 100644 --- a/solapi/lib/string_date_transfer.py +++ b/solapi/lib/string_date_transfer.py @@ -27,7 +27,14 @@ def format_iso(date: datetime) -> str: """ utc_offset_sec = time.altzone if time.localtime().tm_isdst else time.timezone utc_offset = timedelta(seconds=-utc_offset_sec) - return date.replace(tzinfo=timezone(offset=utc_offset)).isoformat() + local_tz = timezone(offset=utc_offset) + + if date.tzinfo is None: + date = date.replace(tzinfo=local_tz) + else: + date = date.astimezone(local_tz) + + return date.isoformat() def parse_iso(date_string: str) -> datetime: diff --git a/solapi/model/__init__.py b/solapi/model/__init__.py index 91e1f0a..b363c20 100644 --- a/solapi/model/__init__.py +++ b/solapi/model/__init__.py @@ -1,6 +1,7 @@ from .kakao.kakao_option import KakaoOption from .message_type import MessageType from .request.groups.get_groups import GetGroupsRequest +from .request.kakao.bms import Bms from .request.message import Message as RequestMessage from .request.send_message_request import SendRequestConfig from .response.message import Message as ResponseMessage @@ -12,4 +13,5 @@ "KakaoOption", "GetGroupsRequest", "MessageType", + "Bms", ] diff --git a/solapi/model/kakao/kakao_option.py b/solapi/model/kakao/kakao_option.py index 348b665..1c977ea 100644 --- a/solapi/model/kakao/kakao_option.py +++ b/solapi/model/kakao/kakao_option.py @@ -4,6 +4,8 @@ from pydantic import BaseModel, ConfigDict, field_validator from pydantic.alias_generators import to_camel +from solapi.model.request.kakao.bms import Bms + class KakaoOption(BaseModel): pf_id: Optional[str] = None @@ -11,6 +13,7 @@ class KakaoOption(BaseModel): variables: Optional[dict[str, str]] = None disable_sms: bool = False image_id: Optional[str] = None + bms: Optional[Bms] = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) @@ -18,5 +21,13 @@ class KakaoOption(BaseModel): @classmethod def stringify_values(cls, v: Mapping[str, object]): if isinstance(v, Mapping): - # 모든 value를 str로 캐스팅 - return {k: str(val) for k, val in v.items()} + # 키값을 #{변수명} 형태로 변환하고 모든 value를 str로 캐스팅 + processed_dict = {} + for k, val in v.items(): + # 키가 이미 #{변수명} 형태가 아니면 자동으로 감싸기 + if not (k.startswith("#{") and k.endswith("}")): + processed_key = f"#{{{k}}}" + else: + processed_key = k + processed_dict[processed_key] = str(val) + return processed_dict diff --git a/solapi/model/request/__init__.py b/solapi/model/request/__init__.py index 39bc86c..0fe91e2 100644 --- a/solapi/model/request/__init__.py +++ b/solapi/model/request/__init__.py @@ -1,2 +1,2 @@ # NOTE: Python SDK가 업데이트 될 때마다 Version도 갱신해야 함! -VERSION = "python/5.0.1" +VERSION = "python/5.0.2" diff --git a/solapi/model/request/groups/get_groups.py b/solapi/model/request/groups/get_groups.py index 7e3eb48..eb43537 100644 --- a/solapi/model/request/groups/get_groups.py +++ b/solapi/model/request/groups/get_groups.py @@ -16,7 +16,7 @@ class GetGroupsRequest(BaseModel): model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) -class GetGroupsCrteriaType(str, Enum): +class GetGroupsCriteriaType(str, Enum): group_id = "groupId" date_created = "dateCreated" scheduled_date = "scheduledDate" diff --git a/solapi/model/request/kakao/bms.py b/solapi/model/request/kakao/bms.py new file mode 100644 index 0000000..9a59c51 --- /dev/null +++ b/solapi/model/request/kakao/bms.py @@ -0,0 +1,12 @@ +from typing import Literal + +from pydantic import BaseModel, ConfigDict + + +class Bms(BaseModel): + targeting: Literal["M", "N", "I"] + + model_config = ConfigDict( + populate_by_name=True, + extra="ignore", + ) diff --git a/solapi/model/request/kakao/kakao_option.py b/solapi/model/request/kakao/kakao_option.py index 6656e2b..42f583a 100644 --- a/solapi/model/request/kakao/kakao_option.py +++ b/solapi/model/request/kakao/kakao_option.py @@ -4,6 +4,7 @@ from pydantic.alias_generators import to_camel from solapi.model.kakao.kakao_button import KakaoButton +from solapi.model.request.kakao.bms import Bms class KakaoOption(BaseModel): @@ -13,5 +14,6 @@ class KakaoOption(BaseModel): disable_sms: bool = False image_id: Optional[str] = None buttons: Optional[list[KakaoButton]] = None + bms: Optional[Bms] = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/request/message.py b/solapi/model/request/message.py index 486dba5..793edc1 100644 --- a/solapi/model/request/message.py +++ b/solapi/model/request/message.py @@ -1,11 +1,12 @@ from typing import Any, Optional, Union -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic.alias_generators import to_camel from solapi.model import KakaoOption from solapi.model.message_type import MessageType from solapi.model.rcs.rcs_options import RcsOption +from solapi.model.request.voice.voice_option import VoiceOption class FileIdsType(BaseModel): @@ -52,6 +53,29 @@ class Message(BaseModel): fax_options: Optional[FileIdsType] = Field( default=None, serialization_alias="faxOptions", validation_alias="faxOptions" ) + voice_options: Optional[VoiceOption] = Field( + default=None, + serialization_alias="voiceOptions", + validation_alias="voiceOptions", + ) + + @field_validator("from_", mode="before") + @classmethod + def normalize_from_phone_number(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + return v.replace("-", "") + + @field_validator("to", mode="before") + @classmethod + def normalize_to_phone_number( + cls, v: Union[str, list[str]] + ) -> Union[str, list[str]]: + if isinstance(v, str): + return v.replace("-", "") + elif isinstance(v, list): + return [phone.replace("-", "") for phone in v] + return v model_config = ConfigDict( extra="ignore", diff --git a/solapi/model/request/voice/voice_option.py b/solapi/model/request/voice/voice_option.py new file mode 100644 index 0000000..32cda58 --- /dev/null +++ b/solapi/model/request/voice/voice_option.py @@ -0,0 +1,18 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class VoiceOption(BaseModel): + voice_type: Literal["FEMALE", "MALE"] + header_message: Optional[str] = None + tail_message: Optional[str] = None + reply_range: Optional[int] = None + counselor_number: Optional[str] = None + + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + extra="ignore", + ) diff --git a/solapi/services/message_service.py b/solapi/services/message_service.py index 01b8904..0d8a5b9 100644 --- a/solapi/services/message_service.py +++ b/solapi/services/message_service.py @@ -9,7 +9,7 @@ from solapi.lib.fetcher import RequestMethod, default_fetcher from solapi.lib.string_date_transfer import format_with_transfer from solapi.model.request.groups.get_groups import ( - GetGroupsCrteriaType, + GetGroupsCriteriaType, GetGroupsFinalizeRequest, GetGroupsRequest, ) @@ -200,7 +200,7 @@ def get_groups(self, query: Optional[GetGroupsRequest] = None) -> GetGroupsRespo if query is not None: request = request.model_copy(update=query.model_dump(exclude_unset=True)) if query.group_id is not None and query.group_id != "": - request.criteria = GetGroupsCrteriaType.group_id + request.criteria = GetGroupsCriteriaType.group_id request.cond = "eq" request.value = query.group_id From 43b538947c31779afb900f98b0cf5740322c5859 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 14 Jul 2025 14:49:04 +0900 Subject: [PATCH 47/60] =?UTF-8?q?=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=ED=86=A1=20=EB=B0=8F=20=EC=9D=8C=EC=84=B1=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EB=B0=9C=EC=86=A1=20=EC=98=88=EC=A0=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20VoiceOption=20=EB=AA=A8=EB=8D=B8=EC=97=90?= =?UTF-8?q?=20reply=5Frange=EC=99=80=20counselor=5Fnumber=EC=9D=98=20?= =?UTF-8?q?=EC=83=81=ED=98=B8=20=EB=B0=B0=ED=83=80=EC=84=B1=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/simple/send_kakao_bms.py | 41 ++++++++++++++++++++++ examples/simple/send_voice_message.py | 38 ++++++++++++++++++++ solapi/model/__init__.py | 2 ++ solapi/model/request/voice/voice_option.py | 12 +++++-- 4 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 examples/simple/send_kakao_bms.py create mode 100644 examples/simple/send_voice_message.py diff --git a/examples/simple/send_kakao_bms.py b/examples/simple/send_kakao_bms.py new file mode 100644 index 0000000..682e3c3 --- /dev/null +++ b/examples/simple/send_kakao_bms.py @@ -0,0 +1,41 @@ +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage + +# API 키와 API Secret을 설정합니다 +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +# 카카오 알림톡 발송을 위한 옵션을 생성합니다. +kakao_option = KakaoOption( + pf_id="계정에 등록된 카카오 비즈니스 채널ID", + template_id="계정에 등록된 카카오 브랜드 메시지 템플릿 ID", + # 만약에 템플릿에 변수가 있다면 아래와 같이 설정합니다. + # 값은 반드시 문자열로 넣어주셔야 합니다! + # variables={ + # "#{name}": "홍길동", + # "#{age}": "30" + # } + # 브랜드 메시지 발송 대상자 설정, M, N 타입은 카카오측의 별도 인허가를 받은 대상만 사용할 수 있습니다. + # M: 마케팅 수신 동의 대상자 및 카카오 채널 친구 + # N: 마케팅 수신 동의 대상자 및 카카오 채널 친구는 제외한 대상자 + # I: 카카오 채널 친구 + bms=Bms(targeting="M"), +) + +# 단일 메시지를 생성합니다 +message = RequestMessage( + from_="발신번호", # 발신번호 (등록된 발신번호만 사용 가능) + to="수신번호", # 수신번호 + kakao_options=kakao_option, +) + +# 메시지를 발송합니다 +try: + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered}") +except Exception as e: + print(f"메시지 발송 실패: {str(e)}") diff --git a/examples/simple/send_voice_message.py b/examples/simple/send_voice_message.py new file mode 100644 index 0000000..12c5db7 --- /dev/null +++ b/examples/simple/send_voice_message.py @@ -0,0 +1,38 @@ +from solapi import SolapiMessageService +from solapi.model import RequestMessage, VoiceOption + +# API 키와 API Secret을 설정합니다 +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +""" +단일 메시지를 생성합니다 +header_message를 사용하는 경우, 반드시 아무 버튼이나 눌러야 text 메시지가 재생됩니다. +text 메시지가 재생된 이후, reply_range에 명시된 번호(1~9) 혹은 counselor_number에 값이 있을 경우 0번을 눌러야 tail_message가 재생됩니다. +자세한 사항은 아래 링크를 참고해주세요! + +https://developers.solapi.com/references/voice +""" +message = RequestMessage( + from_="발신번호", # 발신번호 (등록된 발신번호만 사용 가능) + to="수신번호", # 수신번호 + text="안녕하세요! SOLAPI Python SDK를 사용한 음성 메시지 발송 예제입니다.", + voice_options=VoiceOption( + voice_type="FEMALE", + header_message="안녕하세요!", + tail_message="안녕하세요!", + reply_range=1, + ), +) + +# 메시지를 발송합니다 +try: + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") + print(f"실패한 메시지 개수: {response.group_info.count.registered_failed}") +except Exception as e: + print(f"메시지 발송 실패: {str(e)}") diff --git a/solapi/model/__init__.py b/solapi/model/__init__.py index b363c20..8243ebe 100644 --- a/solapi/model/__init__.py +++ b/solapi/model/__init__.py @@ -4,6 +4,7 @@ from .request.kakao.bms import Bms from .request.message import Message as RequestMessage from .request.send_message_request import SendRequestConfig +from .request.voice.voice_option import VoiceOption from .response.message import Message as ResponseMessage __all__ = [ @@ -14,4 +15,5 @@ "GetGroupsRequest", "MessageType", "Bms", + "VoiceOption", ] diff --git a/solapi/model/request/voice/voice_option.py b/solapi/model/request/voice/voice_option.py index 32cda58..e3fbba2 100644 --- a/solapi/model/request/voice/voice_option.py +++ b/solapi/model/request/voice/voice_option.py @@ -1,6 +1,6 @@ from typing import Literal, Optional -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, model_validator from pydantic.alias_generators import to_camel @@ -8,9 +8,17 @@ class VoiceOption(BaseModel): voice_type: Literal["FEMALE", "MALE"] header_message: Optional[str] = None tail_message: Optional[str] = None - reply_range: Optional[int] = None + reply_range: Optional[Literal[1, 2, 3, 4, 5, 6, 7, 8, 9]] = None counselor_number: Optional[str] = None + @model_validator(mode="after") + def check_exclusive_fields(self) -> "VoiceOption": + if self.reply_range is not None and self.counselor_number is not None: + raise ValueError( + "reply_range와 counselor_number는 같이 사용할 수 없습니다." + ) + return self + model_config = ConfigDict( alias_generator=to_camel, populate_by_name=True, From 8602eab28c9dc686ac886921a3ba8f154a08d943 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Mon, 14 Jul 2025 15:33:11 +0900 Subject: [PATCH 48/60] =?UTF-8?q?pyproject.toml=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B2=84=EC=A0=84=EC=9D=84=205.0.1?= =?UTF-8?q?=EC=97=90=EC=84=9C=205.0.2=EB=A1=9C=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 135faae..c3b7b39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "solapi" -version = "5.0.1" +version = "5.0.2" description = "SOLAPI SDK for Python" authors = [ { name = "SOLAPI Team", email = "contact@solapi.com" } From 53905008cfef24f25810335c944c5acba2e341a4 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 10:22:37 +0900 Subject: [PATCH 49/60] docs: add CLAUDE.md, AGENTS.md for Claude Code guidance Add project documentation files to guide Claude Code when working with this codebase. Includes project overview, architecture details, conventions, and anti-patterns specific to this SDK. Co-Authored-By: Claude Opus 4.5 --- AGENTS.md | 150 +++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 104 ++++++++++++++++++++++++++++ solapi/model/AGENTS.md | 115 +++++++++++++++++++++++++++++++ 3 files changed, 369 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 solapi/model/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8660858 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,150 @@ +# SOLAPI-PYTHON KNOWLEDGE BASE + +**Generated:** 2026-01-21 +**Commit:** b77fdd9 +**Branch:** main + +## OVERVIEW + +Python SDK for SOLAPI messaging platform. Sends SMS/LMS/MMS/Kakao/Naver/RCS messages in Korea. Thin wrapper around REST API using httpx + Pydantic v2. + +## STRUCTURE + +``` +solapi-python/ +├── solapi/ # Main package (single export: SolapiMessageService) +│ ├── services/ # message_service.py - all API operations +│ ├── model/ # Pydantic models (see solapi/model/AGENTS.md) +│ ├── lib/ # authenticator.py, fetcher.py +│ └── error/ # MessageNotReceivedError only +├── tests/ # pytest integration tests +├── examples/ # Feature-based usage examples +└── debug/ # Dev test scripts (not part of package) +``` + +## WHERE TO LOOK + +| Task | Location | Notes | +|------|----------|-------| +| Send messages | `solapi/services/message_service.py` | All 10 API methods in single class | +| Request models | `solapi/model/request/` | Pydantic BaseModel with validators | +| Response models | `solapi/model/response/` | Separate from request models | +| Kakao/Naver/RCS | `solapi/model/{kakao,naver,rcs}/` | Domain-specific models | +| Authentication | `solapi/lib/authenticator.py` | HMAC-SHA256 signature | +| HTTP client | `solapi/lib/fetcher.py` | httpx with 3 retries | +| Test fixtures | `tests/conftest.py` | env-based credentials | +| Usage examples | `examples/simple/` | Copy-paste ready | + +## CONVENTIONS + +### Pydantic Everywhere +- ALL models extend `BaseModel` +- Field aliases: `Field(alias="camelCase")` for API compatibility +- Validators: `@field_validator` for normalization (e.g., phone numbers) + +### Model Organization (Domain-Driven) +``` +model/ +├── request/ # Outbound API payloads +├── response/ # Inbound API responses +├── kakao/ # Kakao-specific (option, button) +├── naver/ # Naver-specific +├── rcs/ # RCS-specific +└── webhook/ # Delivery reports +``` + +### Naming +- Files: `snake_case.py` +- Classes: `PascalCase` +- Request suffix: `*Request` (e.g., `SendMessageRequest`) +- Response suffix: `*Response` (e.g., `SendMessageResponse`) + +### Code Style (Ruff) +- Line length: 88 +- Quote style: double +- Import sorting: isort (I rule) +- Target: Python 3.9+ + +## ANTI-PATTERNS (THIS PROJECT) + +### NEVER +- Add CLI/console scripts - this is library-only +- Create multiple service classes - all goes in `SolapiMessageService` +- Mix request/response models - they're deliberately separate +- Use dataclasses or TypedDict for API models - Pydantic only +- Hardcode credentials - use env vars + +### VERSION SYNC REQUIRED +```python +# solapi/model/request/__init__.py +VERSION = "python/5.0.2" # MUST update on every release! +``` +Also update `pyproject.toml` version. + +## UNIQUE PATTERNS + +### Single Service Class +```python +# All API methods in one class (318 lines) +class SolapiMessageService: + def send(...) # SMS/LMS/MMS/Kakao/Naver/RCS + def upload_file(...) # Storage + def get_balance(...) # Account + def get_groups(...) # Message groups + def get_messages(...) # Message history + def cancel_scheduled_message(...) +``` + +### Minimal Error Handling +- Only `MessageNotReceivedError` exists +- API errors raised as generic `Exception` with errorCode, errorMessage + +### Authentication Flow +``` +SolapiMessageService.__init__(api_key, api_secret) + → Authenticator.get_auth_info() + → HMAC-SHA256 signature + → Authorization header +``` + +## COMMANDS + +```bash +# Install +pip install solapi + +# Dev setup +pip install -e ".[dev]" + +# Lint & format +ruff check --fix . +ruff format . + +# Test (requires env vars) +export SOLAPI_API_KEY="..." +export SOLAPI_API_SECRET="..." +export SOLAPI_SENDER="..." +export SOLAPI_RECIPIENT="..." +pytest + +# Build +python -m build +``` + +## ENV VARS (Testing) + +| Variable | Purpose | +|----------|---------| +| `SOLAPI_API_KEY` | API authentication | +| `SOLAPI_API_SECRET` | API authentication | +| `SOLAPI_SENDER` | Registered sender number | +| `SOLAPI_RECIPIENT` | Test recipient number | +| `SOLAPI_KAKAO_PF_ID` | Kakao business channel | +| `SOLAPI_KAKAO_TEMPLATE_ID` | Kakao template | + +## NOTES + +- No CI/CD pipeline - testing/linting is local only +- uv workspace includes Django webhook example +- Tests are integration tests (hit real API) +- Korean comments in some files (i18n TODO exists) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bf3d015 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,104 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Python SDK for SOLAPI messaging platform. Sends SMS/LMS/MMS/Kakao/Naver/RCS messages in Korea. Thin wrapper around REST API using httpx + Pydantic v2. + +## Commands + +```bash +# Dev setup +pip install -e ".[dev]" + +# Lint & format +ruff check --fix . +ruff format . + +# Test (requires env vars - see below) +pytest +pytest tests/test_balance.py # Single file +pytest -v # Verbose + +# Build +python -m build +``` + +## Testing Environment Variables + +Tests are integration tests that hit the real API: + +| Variable | Purpose | +|----------|---------| +| `SOLAPI_API_KEY` | API authentication | +| `SOLAPI_API_SECRET` | API authentication | +| `SOLAPI_SENDER` | Registered sender number | +| `SOLAPI_RECIPIENT` | Test recipient number | +| `SOLAPI_KAKAO_PF_ID` | Kakao business channel | +| `SOLAPI_KAKAO_TEMPLATE_ID` | Kakao template | + +## Architecture + +### Package Structure +``` +solapi/ +├── services/ # message_service.py - single SolapiMessageService class +├── model/ # Pydantic models (see solapi/model/AGENTS.md) +│ ├── request/ # Outbound API payloads +│ ├── response/ # Inbound API responses (deliberately separate) +│ ├── kakao/ # Kakao channel models +│ ├── naver/ # Naver channel models +│ ├── rcs/ # RCS channel models +│ └── webhook/ # Delivery reports +├── lib/ # authenticator.py, fetcher.py +└── error/ # MessageNotReceivedError only +``` + +### Key Design Decisions + +**Single Service Class**: All 10 API methods live in `SolapiMessageService` - do not create additional service classes. + +**Request/Response Separation**: Request and response models are deliberately separate and should never be shared, even for similar fields. + +**Pydantic Everywhere**: All API models use Pydantic BaseModel with field aliases for camelCase API compatibility: +```python +pf_id: str = Field(alias="pfId") +``` + +**Phone Number Normalization**: Use `@field_validator` to strip dashes from phone numbers. + +### Version Sync Required + +When releasing, update version in BOTH locations: +- `pyproject.toml` → `version = "X.Y.Z"` +- `solapi/model/request/__init__.py` → `VERSION = "python/X.Y.Z"` + +## Code Style + +- **Linter**: Ruff (line-length: 88, double quotes, isort) +- **Target**: Python 3.9+ +- **Files**: `snake_case.py` +- **Classes**: `PascalCase` +- **Request/Response suffixes**: `*Request`, `*Response` + +## Key Locations + +| Task | Location | +|------|----------| +| Send messages | `solapi/services/message_service.py` | +| Request models | `solapi/model/request/` | +| Response models | `solapi/model/response/` | +| Kakao/Naver/RCS | `solapi/model/{kakao,naver,rcs}/` | +| Authentication | `solapi/lib/authenticator.py` | +| HTTP client | `solapi/lib/fetcher.py` | +| Test fixtures | `tests/conftest.py` | +| Usage examples | `examples/simple/` | + +## Anti-Patterns + +- Do not add CLI/console scripts - this is library-only +- Do not create multiple service classes +- Do not mix request/response models +- Do not use dataclasses or TypedDict for API models - Pydantic only +- Do not hardcode credentials diff --git a/solapi/model/AGENTS.md b/solapi/model/AGENTS.md new file mode 100644 index 0000000..d39ebe2 --- /dev/null +++ b/solapi/model/AGENTS.md @@ -0,0 +1,115 @@ +# SOLAPI MODEL LAYER + +## OVERVIEW + +Pydantic v2 models for SOLAPI REST API. Domain-driven organization by messaging channel. + +## STRUCTURE + +``` +model/ +├── request/ # Outbound payloads +│ ├── message.py # Core Message class +│ ├── send_message_request.py +│ ├── storage.py # File upload +│ ├── kakao/ # Kakao BMS, option +│ ├── voice/ # Voice message +│ ├── messages/ # Get messages query +│ └── groups/ # Get groups query +├── response/ # Inbound payloads +│ ├── send_message_response.py +│ ├── common_response.py +│ ├── storage.py +│ ├── balance/ +│ ├── messages/ +│ └── groups/ +├── kakao/ # Kakao channel models +├── naver/ # Naver channel models +├── rcs/ # RCS channel models +└── webhook/ # Delivery reports +``` + +## WHERE TO LOOK + +| Task | File | Notes | +|------|------|-------| +| Build message payload | `request/message.py` | Main `Message` class | +| Send request wrapper | `request/send_message_request.py` | Wraps messages list | +| Handle send response | `response/send_message_response.py` | Parse API response | +| Kakao options | `kakao/kakao_option.py` | PF ID, template, buttons | +| Naver options | `naver/naver_option.py` | Naver talk settings | +| RCS options | `rcs/rcs_options.py` | RCS specific fields | +| Webhook parsing | `webhook/single_report.py` | Delivery status | + +## CONVENTIONS + +### All Models Are Pydantic +```python +from pydantic import BaseModel, Field + +class Message(BaseModel): + to: str = Field(alias="to") # camelCase alias for API +``` + +### Request vs Response Separation +- NEVER share classes between request/response +- Request: what you send to API +- Response: what API returns +- Even similar fields get separate classes + +### Field Aliases for API +```python +# snake_case in Python, camelCase in JSON +pf_id: str = Field(alias="pfId") +template_id: str = Field(alias="templateId") +``` + +### Validators for Normalization +```python +@field_validator("to", mode="before") +@classmethod +def normalize_phone(cls, v: str) -> str: + return v.replace("-", "") # Strip dashes +``` + +### Optional Fields +```python +# Use Optional with None default +subject: Optional[str] = None +image_id: Optional[str] = Field(default=None, alias="imageId") +``` + +## ANTI-PATTERNS + +### NEVER +- Use TypedDict for API models (Pydantic only) +- Share model between request/response +- Forget alias when API uses camelCase +- Skip validators for phone numbers + +### VERSION IN THIS PACKAGE +```python +# request/__init__.py line 1-2 +VERSION = "python/5.0.2" # Sync with pyproject.toml! +``` + +## KEY CLASSES + +### Request Side +- `Message` - Core message with to, from, text, type +- `SendMessageRequest` - Wrapper with messages list + version +- `SendRequestConfig` - app_id, scheduled_date, allow_duplicates +- `KakaoOption` - Kakao-specific (pfId, templateId, buttons) +- `FileUploadRequest` - Base64 encoded file + +### Response Side +- `SendMessageResponse` - Contains group_info, failed_message_list +- `GroupMessageResponse` - Generic group response +- `GetBalanceResponse` - balance, point fields +- `FileUploadResponse` - fileId for uploaded files + +## NOTES + +- Korean comments exist (i18n TODO) +- Some TODOs for future field additions (kakao button types, group count fields) +- Webhook models for delivery status callbacks From efc3edfbbcb8288a3b06dcd708cc1b8cc49abd99 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 11:15:28 +0900 Subject: [PATCH 50/60] =?UTF-8?q?chore:=20Claude=20Code=20Hooks=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EC=9C=BC=EB=A1=9C=20Python=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20lint/fix=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python 파일 수정 시 자동으로 실행되는 Hook 설정: - ruff check --fix: 린트 오류 자동 수정 - ruff format: 코드 포매팅 - ty check: 타입 체크 (오류 시 Claude에게 수정 요청) PostToolUse 이벤트에서 Edit/Write/NotebookEdit 도구 사용 후 실행됩니다. Co-Authored-By: Claude Opus 4.5 --- .claude/hooks/lint-python.py | 65 ++++++++++++++++++++++++++++++++++++ .claude/settings.json | 15 +++++++++ pyproject.toml | 3 +- uv.lock | 28 +++++++++++++++- 4 files changed, 109 insertions(+), 2 deletions(-) create mode 100755 .claude/hooks/lint-python.py create mode 100644 .claude/settings.json diff --git a/.claude/hooks/lint-python.py b/.claude/hooks/lint-python.py new file mode 100755 index 0000000..af3574d --- /dev/null +++ b/.claude/hooks/lint-python.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +""" +Claude Code Hook: Python 파일 자동 lint/fix +- ruff check --fix: 린트 오류 자동 수정 +- ruff format: 코드 포매팅 +- ty check: 타입 체크 (오류 시 exit code 1로 Claude에게 수정 요청) +""" +import json +import os +import subprocess +import sys + + +def main(): + try: + input_data = json.load(sys.stdin) + except json.JSONDecodeError: + return 0 + + file_path = input_data.get("tool_input", {}).get("file_path", "") + + # Python 파일만 처리 + if not file_path or not file_path.endswith(".py"): + return 0 + + project_dir = os.environ.get("CLAUDE_PROJECT_DIR", "") + if not project_dir: + return 0 + + os.chdir(project_dir) + + # 1. ruff check --fix + subprocess.run( + ["ruff", "check", "--fix", file_path], + capture_output=True, + text=True, + timeout=30, + ) + + # 2. ruff format + subprocess.run( + ["ruff", "format", file_path], + capture_output=True, + text=True, + timeout=30, + ) + + # 3. ty check (타입 오류 시 차단) + result = subprocess.run( + ["ty", "check", file_path], + capture_output=True, + text=True, + timeout=60, + ) + if result.returncode != 0: + output = result.stdout.strip() or result.stderr.strip() + if output: + print(f"ty type error:\n{output}", file=sys.stderr) + return 1 # 타입 오류 시 Hook 실패 → Claude가 수정하도록 함 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..9f9706d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/lint-python.py\"" + } + ] + } + ] + } +} diff --git a/pyproject.toml b/pyproject.toml index c3b7b39..6698f79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,8 @@ dependencies = [ [project.optional-dependencies] dev = [ "ruff>=0.11.0", - "pytest>=7.0.0" + "pytest>=7.0.0", + "ty>=0.0.1" ] [build-system] diff --git a/uv.lock b/uv.lock index 4d60c0a..dc4604e 100644 --- a/uv.lock +++ b/uv.lock @@ -375,7 +375,7 @@ wheels = [ [[package]] name = "solapi" -version = "5.0.1" +version = "5.0.2" source = { editable = "." } dependencies = [ { name = "httpx" }, @@ -386,6 +386,7 @@ dependencies = [ dev = [ { name = "pytest" }, { name = "ruff" }, + { name = "ty" }, ] [package.metadata] @@ -394,6 +395,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.11.4,<3.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11.0" }, + { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1" }, ] provides-extras = ["dev"] @@ -445,6 +447,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, ] +[[package]] +name = "ty" +version = "0.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/dc/b607f00916f5a7c52860b84a66dc17bc6988e8445e96b1d6e175a3837397/ty-0.0.13.tar.gz", hash = "sha256:7a1d135a400ca076407ea30012d1f75419634160ed3b9cad96607bf2956b23b3", size = 4999183 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/df/3632f1918f4c0a33184f107efc5d436ab6da147fd3d3b94b3af6461efbf4/ty-0.0.13-py3-none-linux_armv6l.whl", hash = "sha256:1b2b8e02697c3a94c722957d712a0615bcc317c9b9497be116ef746615d892f2", size = 9993501 }, + { url = "https://files.pythonhosted.org/packages/92/87/6a473ced5ac280c6ce5b1627c71a8a695c64481b99aabc798718376a441e/ty-0.0.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f15cdb8e233e2b5adfce673bb21f4c5e8eaf3334842f7eea3c70ac6fda8c1de5", size = 9860986 }, + { url = "https://files.pythonhosted.org/packages/5d/9b/d89ae375cf0a7cd9360e1164ce017f8c753759be63b6a11ed4c944abe8c6/ty-0.0.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0819e89ac9f0d8af7a062837ce197f0461fee2fc14fd07e2c368780d3a397b73", size = 9350748 }, + { url = "https://files.pythonhosted.org/packages/a8/a6/9ad58518056fab344b20c0bb2c1911936ebe195318e8acc3bc45ac1c6b6b/ty-0.0.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1de79f481084b7cc7a202ba0d7a75e10970d10ffa4f025b23f2e6b7324b74886", size = 9849884 }, + { url = "https://files.pythonhosted.org/packages/b1/c3/8add69095fa179f523d9e9afcc15a00818af0a37f2b237a9b59bc0046c34/ty-0.0.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4fb2154cff7c6e95d46bfaba283c60642616f20d73e5f96d0c89c269f3e1bcec", size = 9822975 }, + { url = "https://files.pythonhosted.org/packages/a4/05/4c0927c68a0a6d43fb02f3f0b6c19c64e3461dc8ed6c404dde0efb8058f7/ty-0.0.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00be58d89337c27968a20d58ca553458608c5b634170e2bec82824c2e4cf4d96", size = 10294045 }, + { url = "https://files.pythonhosted.org/packages/b4/86/6dc190838aba967557fe0bfd494c595d00b5081315a98aaf60c0e632aaeb/ty-0.0.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72435eade1fa58c6218abb4340f43a6c3ff856ae2dc5722a247d3a6dd32e9737", size = 10916460 }, + { url = "https://files.pythonhosted.org/packages/04/40/9ead96b7c122e1109dfcd11671184c3506996bf6a649306ec427e81d9544/ty-0.0.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:77a548742ee8f621d718159e7027c3b555051d096a49bb580249a6c5fc86c271", size = 10597154 }, + { url = "https://files.pythonhosted.org/packages/aa/7d/e832a2c081d2be845dc6972d0c7998914d168ccbc0b9c86794419ab7376e/ty-0.0.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da067c57c289b7cf914669704b552b6207c2cc7f50da4118c3e12388642e6b3f", size = 10410710 }, + { url = "https://files.pythonhosted.org/packages/31/e3/898be3a96237a32f05c4c29b43594dc3b46e0eedfe8243058e46153b324f/ty-0.0.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d1b50a01fffa140417fca5a24b658fbe0734074a095d5b6f0552484724474343", size = 9826299 }, + { url = "https://files.pythonhosted.org/packages/bb/eb/db2d852ce0ed742505ff18ee10d7d252f3acfd6fc60eca7e9c7a0288a6d8/ty-0.0.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0f33c46f52e5e9378378eca0d8059f026f3c8073ace02f7f2e8d079ddfe5207e", size = 9831610 }, + { url = "https://files.pythonhosted.org/packages/9e/61/149f59c8abaddcbcbb0bd13b89c7741ae1c637823c5cf92ed2c644fcadef/ty-0.0.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:168eda24d9a0b202cf3758c2962cc295878842042b7eca9ed2965259f59ce9f2", size = 9978885 }, + { url = "https://files.pythonhosted.org/packages/a0/cd/026d4e4af60a80918a8d73d2c42b8262dd43ab2fa7b28d9743004cb88d57/ty-0.0.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d4917678b95dc8cb399cc459fab568ba8d5f0f33b7a94bf840d9733043c43f29", size = 10506453 }, + { url = "https://files.pythonhosted.org/packages/63/06/8932833a4eca2df49c997a29afb26721612de8078ae79074c8fe87e17516/ty-0.0.13-py3-none-win32.whl", hash = "sha256:c1f2ec40daa405508b053e5b8e440fbae5fdb85c69c9ab0ee078f8bc00eeec3d", size = 9433482 }, + { url = "https://files.pythonhosted.org/packages/aa/fd/e8d972d1a69df25c2cecb20ea50e49ad5f27a06f55f1f5f399a563e71645/ty-0.0.13-py3-none-win_amd64.whl", hash = "sha256:8b7b1ab9f187affbceff89d51076038363b14113be29bda2ddfa17116de1d476", size = 10319156 }, + { url = "https://files.pythonhosted.org/packages/2d/c2/05fdd64ac003a560d4fbd1faa7d9a31d75df8f901675e5bed1ee2ceeff87/ty-0.0.13-py3-none-win_arm64.whl", hash = "sha256:1c9630333497c77bb9bcabba42971b96ee1f36c601dd3dcac66b4134f9fa38f0", size = 9808316 }, +] + [[package]] name = "typing-extensions" version = "4.12.2" From d5c5dbf2ae7811faf8f160234673640fb5cb7cc9 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 11:23:21 +0900 Subject: [PATCH 51/60] docs: add Tidy First principles to CLAUDE.md and AGENTS.md Add Kent Beck's "Tidy First?" guidelines to enforce separation of structural (refactoring) and behavioral (feature) changes in commits. Co-Authored-By: Claude Opus 4.5 --- AGENTS.md | 6 ++++++ CLAUDE.md | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 8660858..e2dec6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,6 +65,12 @@ model/ - Import sorting: isort (I rule) - Target: Python 3.9+ +### Tidy First Principles +- Never mix refactoring and feature changes in the same commit +- Tidy related code before making behavioral changes +- Tidying: guard clauses, dead code removal, rename, extract conditionals +- Separate tidying commits from feature commits + ## ANTI-PATTERNS (THIS PROJECT) ### NEVER diff --git a/CLAUDE.md b/CLAUDE.md index bf3d015..948123a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,26 @@ When releasing, update version in BOTH locations: - **Classes**: `PascalCase` - **Request/Response suffixes**: `*Request`, `*Response` +## Tidy First Principles + +Follow Kent Beck's "Tidy First?" principles: + +### Separate Changes +- Never mix **structural changes** (refactoring) with **behavioral changes** (features/fixes) in the same commit +- Order: tidying commit → feature commit + +### Tidy First +Tidy the relevant code area before making behavioral changes: +- Use guard clauses to reduce nesting +- Remove dead code +- Rename for clarity +- Extract complex conditionals + +### Small Steps +- Keep tidying changes small and safe +- One tidying per commit +- Maintain passing tests + ## Key Locations | Task | Location | From 4f5f51c9f788639ebd36cb8b709eb9ff8b6caea0 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 14:21:20 +0900 Subject: [PATCH 52/60] Update version to 5.0.3 and add BMS (Brand Message Service) models - Incremented version in pyproject.toml from 5.0.2 to 5.0.3. - Introduced new BMS message types in message_type.py. - Added BMS-related models including Bms, BmsButton, BmsCarousel, BmsCommerce, BmsCoupon, BmsVideo, and BmsOption. - Implemented validation logic for BMS models to ensure required fields are present. - Added tests for BMS functionality to ensure proper behavior and validation. This update enhances the SDK's capabilities for handling brand messaging services. --- pyproject.toml | 2 +- solapi/model/kakao/bms/__init__.py | 62 ++ solapi/model/kakao/bms/bms_button.py | 117 +++ solapi/model/kakao/bms/bms_carousel.py | 68 ++ solapi/model/kakao/bms/bms_commerce.py | 73 ++ solapi/model/kakao/bms/bms_coupon.py | 55 ++ solapi/model/kakao/bms/bms_option.py | 94 +++ solapi/model/kakao/bms/bms_video.py | 24 + solapi/model/kakao/bms/bms_wide_item.py | 26 + solapi/model/message_type.py | 18 + solapi/model/request/kakao/bms.py | 95 ++- solapi/model/request/message.py | 1 + solapi/model/request/storage.py | 7 + tests/test_bms_free.py | 903 ++++++++++++++++++++++++ 14 files changed, 1541 insertions(+), 4 deletions(-) create mode 100644 solapi/model/kakao/bms/__init__.py create mode 100644 solapi/model/kakao/bms/bms_button.py create mode 100644 solapi/model/kakao/bms/bms_carousel.py create mode 100644 solapi/model/kakao/bms/bms_commerce.py create mode 100644 solapi/model/kakao/bms/bms_coupon.py create mode 100644 solapi/model/kakao/bms/bms_option.py create mode 100644 solapi/model/kakao/bms/bms_video.py create mode 100644 solapi/model/kakao/bms/bms_wide_item.py create mode 100644 tests/test_bms_free.py diff --git a/pyproject.toml b/pyproject.toml index 6698f79..4794770 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "solapi" -version = "5.0.2" +version = "5.0.3" description = "SOLAPI SDK for Python" authors = [ { name = "SOLAPI Team", email = "contact@solapi.com" } diff --git a/solapi/model/kakao/bms/__init__.py b/solapi/model/kakao/bms/__init__.py new file mode 100644 index 0000000..915c673 --- /dev/null +++ b/solapi/model/kakao/bms/__init__.py @@ -0,0 +1,62 @@ +"""BMS (카카오 브랜드 메시지) 자유형 모델.""" + +from solapi.model.kakao.bms.bms_button import ( + BmsAppButton, + BmsBotKeywordButton, + BmsBotTransferButton, + BmsBusinessFormButton, + BmsButton, + BmsButtonLinkType, + BmsChannelAddButton, + BmsConsultButton, + BmsLinkButton, + BmsMessageDeliveryButton, + BmsWebButton, +) +from solapi.model.kakao.bms.bms_carousel import ( + BmsCarouselCommerceItem, + BmsCarouselCommerceSchema, + BmsCarouselFeedItem, + BmsCarouselFeedSchema, + BmsCarouselHead, + BmsCarouselTail, +) +from solapi.model.kakao.bms.bms_commerce import BmsCommerce +from solapi.model.kakao.bms.bms_coupon import BmsCoupon +from solapi.model.kakao.bms.bms_option import BmsChatBubbleType, BmsOption +from solapi.model.kakao.bms.bms_video import BmsVideo +from solapi.model.kakao.bms.bms_wide_item import BmsMainWideItem, BmsSubWideItem + +__all__ = [ + # Button types + "BmsButtonLinkType", + "BmsWebButton", + "BmsAppButton", + "BmsChannelAddButton", + "BmsBotKeywordButton", + "BmsMessageDeliveryButton", + "BmsConsultButton", + "BmsBotTransferButton", + "BmsBusinessFormButton", + "BmsButton", + "BmsLinkButton", + # Commerce + "BmsCommerce", + # Coupon + "BmsCoupon", + # Video + "BmsVideo", + # Wide Item + "BmsMainWideItem", + "BmsSubWideItem", + # Carousel + "BmsCarouselHead", + "BmsCarouselTail", + "BmsCarouselFeedItem", + "BmsCarouselFeedSchema", + "BmsCarouselCommerceItem", + "BmsCarouselCommerceSchema", + # Option + "BmsChatBubbleType", + "BmsOption", +] diff --git a/solapi/model/kakao/bms/bms_button.py b/solapi/model/kakao/bms/bms_button.py new file mode 100644 index 0000000..9e0ba81 --- /dev/null +++ b/solapi/model/kakao/bms/bms_button.py @@ -0,0 +1,117 @@ +from typing import Annotated, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, model_validator +from pydantic.alias_generators import to_camel + +BmsButtonLinkType = Literal["AC", "WL", "AL", "BK", "MD", "BC", "BT", "BF"] + + +class BmsWebButton(BaseModel): + """WL: 웹 링크 버튼.""" + + link_type: Literal["WL"] = "WL" + name: str + link_mobile: str + link_pc: Optional[str] = None + target_out: Optional[bool] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsAppButton(BaseModel): + """AL: 앱 링크 버튼. linkMobile, linkAndroid, linkIos 중 하나 이상 필수.""" + + link_type: Literal["AL"] = "AL" + name: str + link_mobile: Optional[str] = None + link_android: Optional[str] = None + link_ios: Optional[str] = None + target_out: Optional[bool] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + @model_validator(mode="after") + def validate_at_least_one_link(self) -> "BmsAppButton": + if not any([self.link_mobile, self.link_android, self.link_ios]): + raise ValueError( + "AL 타입 버튼은 linkMobile, linkAndroid, linkIos 중 하나 이상 필수입니다." + ) + return self + + +class BmsChannelAddButton(BaseModel): + """AC: 채널 추가 버튼.""" + + link_type: Literal["AC"] = "AC" + name: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsBotKeywordButton(BaseModel): + """BK: 봇 키워드 버튼.""" + + link_type: Literal["BK"] = "BK" + name: str + chat_extra: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsMessageDeliveryButton(BaseModel): + """MD: 메시지 전달 버튼.""" + + link_type: Literal["MD"] = "MD" + name: str + chat_extra: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsConsultButton(BaseModel): + """BC: 상담 요청 버튼.""" + + link_type: Literal["BC"] = "BC" + name: str + chat_extra: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsBotTransferButton(BaseModel): + """BT: 봇 전환 버튼.""" + + link_type: Literal["BT"] = "BT" + name: str + chat_extra: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsBusinessFormButton(BaseModel): + """BF: 비즈니스폼 버튼.""" + + link_type: Literal["BF"] = "BF" + name: str + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +BmsButton = Annotated[ + Union[ + BmsWebButton, + BmsAppButton, + BmsChannelAddButton, + BmsBotKeywordButton, + BmsMessageDeliveryButton, + BmsConsultButton, + BmsBotTransferButton, + BmsBusinessFormButton, + ], + "BMS 버튼 통합 타입 (linkType으로 구분)", +] + +BmsLinkButton = Annotated[ + Union[BmsWebButton, BmsAppButton], + "BMS 링크 버튼 (WL, AL만 허용) - 캐러셀 등에서 사용", +] diff --git a/solapi/model/kakao/bms/bms_carousel.py b/solapi/model/kakao/bms/bms_carousel.py new file mode 100644 index 0000000..f8b0d9c --- /dev/null +++ b/solapi/model/kakao/bms/bms_carousel.py @@ -0,0 +1,68 @@ +from typing import List, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + +from solapi.model.kakao.bms.bms_button import BmsAppButton, BmsWebButton +from solapi.model.kakao.bms.bms_commerce import BmsCommerce +from solapi.model.kakao.bms.bms_coupon import BmsCoupon + +BmsLinkButton = Union[BmsWebButton, BmsAppButton] + + +class BmsCarouselHead(BaseModel): + header: Optional[str] = None + content: Optional[str] = None + image_id: Optional[str] = None + link_mobile: Optional[str] = None + link_pc: Optional[str] = None + link_android: Optional[str] = None + link_ios: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsCarouselTail(BaseModel): + link_mobile: Optional[str] = None + link_pc: Optional[str] = None + link_android: Optional[str] = None + link_ios: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsCarouselFeedItem(BaseModel): + header: Optional[str] = None + content: Optional[str] = None + image_id: Optional[str] = None + image_link: Optional[str] = None + buttons: Optional[List[BmsLinkButton]] = None + coupon: Optional[BmsCoupon] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsCarouselFeedSchema(BaseModel): + items: Optional[List[BmsCarouselFeedItem]] = Field(default=None, alias="list") + tail: Optional[BmsCarouselTail] = None + + model_config = ConfigDict(populate_by_name=True) + + +class BmsCarouselCommerceItem(BaseModel): + commerce: Optional[BmsCommerce] = None + image_id: Optional[str] = None + image_link: Optional[str] = None + buttons: Optional[List[BmsLinkButton]] = None + additional_content: Optional[str] = None + coupon: Optional[BmsCoupon] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsCarouselCommerceSchema(BaseModel): + head: Optional[BmsCarouselHead] = None + items: Optional[List[BmsCarouselCommerceItem]] = Field(default=None, alias="list") + tail: Optional[BmsCarouselTail] = None + + model_config = ConfigDict(populate_by_name=True) diff --git a/solapi/model/kakao/bms/bms_commerce.py b/solapi/model/kakao/bms/bms_commerce.py new file mode 100644 index 0000000..d6d72f4 --- /dev/null +++ b/solapi/model/kakao/bms/bms_commerce.py @@ -0,0 +1,73 @@ +from typing import Optional, Union + +from pydantic import BaseModel, ConfigDict, field_validator, model_validator +from pydantic.alias_generators import to_camel + + +class BmsCommerce(BaseModel): + title: Optional[str] = None + regular_price: Optional[int] = None + discount_price: Optional[int] = None + discount_rate: Optional[int] = None + discount_fixed: Optional[int] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + @field_validator( + "regular_price", + "discount_price", + "discount_rate", + "discount_fixed", + mode="before", + ) + @classmethod + def coerce_to_int(cls, v: Union[int, float, str, None]) -> Optional[int]: + if v is None: + return None + if isinstance(v, str): + v = v.strip() + if not v: + return None + return int(float(v)) + return int(v) + + @model_validator(mode="after") + def validate_price_combination(self) -> "BmsCommerce": + if self.regular_price is None: + return self + + has_discount_price = self.discount_price is not None + has_discount_rate = self.discount_rate is not None + has_discount_fixed = self.discount_fixed is not None + + if not has_discount_price and not has_discount_rate and not has_discount_fixed: + return self + + if has_discount_price and has_discount_rate and not has_discount_fixed: + return self + + if has_discount_price and has_discount_fixed and not has_discount_rate: + return self + + if has_discount_rate and has_discount_fixed: + raise ValueError( + "discountRate와 discountFixed는 동시에 사용할 수 없습니다. " + "할인율(discountRate) 또는 정액할인(discountFixed) 중 하나만 선택하세요." + ) + + if not has_discount_price and (has_discount_rate or has_discount_fixed): + raise ValueError( + "discountRate 또는 discountFixed를 사용하려면 " + "discountPrice(할인가)도 함께 지정해야 합니다." + ) + + if has_discount_price and not has_discount_rate and not has_discount_fixed: + raise ValueError( + "discountPrice를 사용하려면 discountRate(할인율) 또는 " + "discountFixed(정액할인) 중 하나를 함께 지정해야 합니다." + ) + + raise ValueError( + "알 수 없는 가격 조합입니다. regularPrice만 사용하거나, " + "regularPrice + discountPrice + discountRate/discountFixed 조합을 사용하세요." + ) diff --git a/solapi/model/kakao/bms/bms_coupon.py b/solapi/model/kakao/bms/bms_coupon.py new file mode 100644 index 0000000..28c7dca --- /dev/null +++ b/solapi/model/kakao/bms/bms_coupon.py @@ -0,0 +1,55 @@ +import re +from typing import Optional + +from pydantic import BaseModel, ConfigDict, field_validator +from pydantic.alias_generators import to_camel + +WON_DISCOUNT_PATTERN = re.compile(r"^([1-9]\d{0,7})원 할인 쿠폰$") +PERCENT_DISCOUNT_PATTERN = re.compile(r"^([1-9]\d?|100)% 할인 쿠폰$") +FREE_COUPON_PATTERN = re.compile(r"^.{1,7} 무료 쿠폰$") +UP_COUPON_PATTERN = re.compile(r"^.{1,7} UP 쿠폰$") + + +def _is_valid_coupon_title(title: str) -> bool: + if title == "배송비 할인 쿠폰": + return True + + won_match = WON_DISCOUNT_PATTERN.match(title) + if won_match: + num = int(won_match.group(1)) + return 1 <= num <= 99_999_999 + + if PERCENT_DISCOUNT_PATTERN.match(title): + return True + + if FREE_COUPON_PATTERN.match(title): + return True + + return bool(UP_COUPON_PATTERN.match(title)) + + +class BmsCoupon(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + link_mobile: Optional[str] = None + link_pc: Optional[str] = None + link_android: Optional[str] = None + link_ios: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + @field_validator("title") + @classmethod + def validate_coupon_title(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + if not _is_valid_coupon_title(v): + raise ValueError( + "쿠폰 제목은 다음 형식 중 하나여야 합니다: " + '"N원 할인 쿠폰" (1~99999999), ' + '"N% 할인 쿠폰" (1~100), ' + '"배송비 할인 쿠폰", ' + '"OOO 무료 쿠폰" (7자 이내), ' + '"OOO UP 쿠폰" (7자 이내)' + ) + return v diff --git a/solapi/model/kakao/bms/bms_option.py b/solapi/model/kakao/bms/bms_option.py new file mode 100644 index 0000000..ee6f07f --- /dev/null +++ b/solapi/model/kakao/bms/bms_option.py @@ -0,0 +1,94 @@ +from typing import Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, model_validator +from pydantic.alias_generators import to_camel + +from solapi.model.kakao.bms.bms_button import BmsButton +from solapi.model.kakao.bms.bms_carousel import ( + BmsCarouselCommerceSchema, + BmsCarouselFeedSchema, +) +from solapi.model.kakao.bms.bms_commerce import BmsCommerce +from solapi.model.kakao.bms.bms_coupon import BmsCoupon +from solapi.model.kakao.bms.bms_video import BmsVideo +from solapi.model.kakao.bms.bms_wide_item import BmsMainWideItem, BmsSubWideItem + +BmsChatBubbleType = Literal[ + "TEXT", + "IMAGE", + "WIDE", + "WIDE_ITEM_LIST", + "COMMERCE", + "CAROUSEL_FEED", + "CAROUSEL_COMMERCE", + "PREMIUM_VIDEO", +] + +BMS_REQUIRED_FIELDS: dict[BmsChatBubbleType, list[str]] = { + "TEXT": [], + "IMAGE": ["image_id"], + "WIDE": ["image_id"], + "WIDE_ITEM_LIST": ["header", "main_wide_item", "sub_wide_item_list"], + "COMMERCE": ["image_id", "commerce", "buttons"], + "CAROUSEL_FEED": ["carousel"], + "CAROUSEL_COMMERCE": ["carousel"], + "PREMIUM_VIDEO": ["video"], +} + +WIDE_ITEM_LIST_MIN_SUB_ITEMS = 3 + + +class BmsOption(BaseModel): + targeting: Literal["I", "M", "N"] + chat_bubble_type: BmsChatBubbleType + + adult: Optional[bool] = None + header: Optional[str] = None + image_id: Optional[str] = None + image_link: Optional[str] = None + additional_content: Optional[str] = None + content: Optional[str] = None + + carousel: Optional[Union[BmsCarouselFeedSchema, BmsCarouselCommerceSchema]] = None + main_wide_item: Optional[BmsMainWideItem] = None + sub_wide_item_list: Optional[list[BmsSubWideItem]] = None + buttons: Optional[list[BmsButton]] = None + coupon: Optional[BmsCoupon] = None + commerce: Optional[BmsCommerce] = None + video: Optional[BmsVideo] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + @model_validator(mode="after") + def validate_required_fields(self) -> "BmsOption": + chat_bubble_type = self.chat_bubble_type + required_fields = BMS_REQUIRED_FIELDS.get(chat_bubble_type, []) + missing_fields = [ + field for field in required_fields if getattr(self, field, None) is None + ] + + if missing_fields: + camel_fields = [_to_camel(f) for f in missing_fields] + raise ValueError( + f"BMS {chat_bubble_type} 타입에 필수 필드가 누락되었습니다: " + f"{', '.join(camel_fields)}" + ) + + if chat_bubble_type == "WIDE_ITEM_LIST": + sub_wide_item_list = self.sub_wide_item_list + if ( + not sub_wide_item_list + or len(sub_wide_item_list) < WIDE_ITEM_LIST_MIN_SUB_ITEMS + ): + raise ValueError( + f"WIDE_ITEM_LIST 타입의 subWideItemList는 최소 " + f"{WIDE_ITEM_LIST_MIN_SUB_ITEMS}개 이상이어야 합니다. " + f"현재: {len(sub_wide_item_list) if sub_wide_item_list else 0}개" + ) + + return self + + +def _to_camel(s: str) -> str: + components = s.split("_") + return components[0] + "".join(x.title() for x in components[1:]) diff --git a/solapi/model/kakao/bms/bms_video.py b/solapi/model/kakao/bms/bms_video.py new file mode 100644 index 0000000..4e6eecb --- /dev/null +++ b/solapi/model/kakao/bms/bms_video.py @@ -0,0 +1,24 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict, field_validator +from pydantic.alias_generators import to_camel + +KAKAO_TV_URL_PREFIX = "https://tv.kakao.com/" + + +class BmsVideo(BaseModel): + video_url: str + image_id: Optional[str] = None + image_link: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + @field_validator("video_url") + @classmethod + def validate_kakao_tv_url(cls, v: str) -> str: + if not v.startswith(KAKAO_TV_URL_PREFIX): + raise ValueError( + f"videoUrl은 '{KAKAO_TV_URL_PREFIX}'으로 시작하는 " + "카카오TV 동영상 링크여야 합니다." + ) + return v diff --git a/solapi/model/kakao/bms/bms_wide_item.py b/solapi/model/kakao/bms/bms_wide_item.py new file mode 100644 index 0000000..a6c5bcd --- /dev/null +++ b/solapi/model/kakao/bms/bms_wide_item.py @@ -0,0 +1,26 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class BmsMainWideItem(BaseModel): + title: Optional[str] = None + image_id: Optional[str] = None + link_mobile: Optional[str] = None + link_pc: Optional[str] = None + link_android: Optional[str] = None + link_ios: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class BmsSubWideItem(BaseModel): + title: Optional[str] = None + image_id: Optional[str] = None + link_mobile: Optional[str] = None + link_pc: Optional[str] = None + link_android: Optional[str] = None + link_ios: Optional[str] = None + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) diff --git a/solapi/model/message_type.py b/solapi/model/message_type.py index cfa8b4c..652b5e2 100644 --- a/solapi/model/message_type.py +++ b/solapi/model/message_type.py @@ -19,6 +19,15 @@ class MessageType(Enum): RCS_LTPL: RCS LMS 템플릿 문자 FAX: 팩스 VOICE: 음성문자(TTS) + BMS_TEXT: 브랜드 메시지 텍스트형 + BMS_IMAGE: 브랜드 메시지 이미지형 + BMS_WIDE: 브랜드 메시지 와이드형 + BMS_WIDE_ITEM_LIST: 브랜드 메시지 와이드 아이템 리스트형 + BMS_CAROUSEL_FEED: 브랜드 메시지 캐러셀 피드형 + BMS_PREMIUM_VIDEO: 브랜드 메시지 프리미엄 비디오형 + BMS_COMMERCE: 브랜드 메시지 커머스형 + BMS_CAROUSEL_COMMERCE: 브랜드 메시지 캐러셀 커머스형 + BMS_FREE: 브랜드 메시지 자유형 """ SMS = "SMS" @@ -36,6 +45,15 @@ class MessageType(Enum): RCS_LTPL = "RCS_LTPL" FAX = "FAX" VOICE = "VOICE" + BMS_TEXT = "BMS_TEXT" + BMS_IMAGE = "BMS_IMAGE" + BMS_WIDE = "BMS_WIDE" + BMS_WIDE_ITEM_LIST = "BMS_WIDE_ITEM_LIST" + BMS_CAROUSEL_FEED = "BMS_CAROUSEL_FEED" + BMS_PREMIUM_VIDEO = "BMS_PREMIUM_VIDEO" + BMS_COMMERCE = "BMS_COMMERCE" + BMS_CAROUSEL_COMMERCE = "BMS_CAROUSEL_COMMERCE" + BMS_FREE = "BMS_FREE" def __str__(self) -> str: return self.value diff --git a/solapi/model/request/kakao/bms.py b/solapi/model/request/kakao/bms.py index 9a59c51..6f59810 100644 --- a/solapi/model/request/kakao/bms.py +++ b/solapi/model/request/kakao/bms.py @@ -1,12 +1,101 @@ -from typing import Literal +from typing import Literal, Optional, Union -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, model_validator +from pydantic.alias_generators import to_camel + +from solapi.model.kakao.bms.bms_button import BmsButton +from solapi.model.kakao.bms.bms_carousel import ( + BmsCarouselCommerceSchema, + BmsCarouselFeedSchema, +) +from solapi.model.kakao.bms.bms_commerce import BmsCommerce +from solapi.model.kakao.bms.bms_coupon import BmsCoupon +from solapi.model.kakao.bms.bms_video import BmsVideo +from solapi.model.kakao.bms.bms_wide_item import BmsMainWideItem, BmsSubWideItem + +BmsChatBubbleType = Literal[ + "TEXT", + "IMAGE", + "WIDE", + "WIDE_ITEM_LIST", + "COMMERCE", + "CAROUSEL_FEED", + "CAROUSEL_COMMERCE", + "PREMIUM_VIDEO", +] + +BMS_REQUIRED_FIELDS: dict[BmsChatBubbleType, list[str]] = { + "TEXT": [], + "IMAGE": ["image_id"], + "WIDE": ["image_id"], + "WIDE_ITEM_LIST": ["header", "main_wide_item", "sub_wide_item_list"], + "COMMERCE": ["image_id", "commerce", "buttons"], + "CAROUSEL_FEED": ["carousel"], + "CAROUSEL_COMMERCE": ["carousel"], + "PREMIUM_VIDEO": ["video"], +} + +WIDE_ITEM_LIST_MIN_SUB_ITEMS = 3 + + +def _to_camel(s: str) -> str: + components = s.split("_") + return components[0] + "".join(x.title() for x in components[1:]) class Bms(BaseModel): - targeting: Literal["M", "N", "I"] + targeting: Optional[Literal["I", "M", "N"]] = None + chat_bubble_type: Optional[BmsChatBubbleType] = None + + adult: Optional[bool] = None + header: Optional[str] = None + image_id: Optional[str] = None + image_link: Optional[str] = None + additional_content: Optional[str] = None + content: Optional[str] = None + + carousel: Optional[Union[BmsCarouselFeedSchema, BmsCarouselCommerceSchema]] = None + main_wide_item: Optional[BmsMainWideItem] = None + sub_wide_item_list: Optional[list[BmsSubWideItem]] = None + buttons: Optional[list[BmsButton]] = None + coupon: Optional[BmsCoupon] = None + commerce: Optional[BmsCommerce] = None + video: Optional[BmsVideo] = None model_config = ConfigDict( + alias_generator=to_camel, populate_by_name=True, extra="ignore", ) + + @model_validator(mode="after") + def validate_required_fields(self) -> "Bms": + chat_bubble_type = self.chat_bubble_type + if chat_bubble_type is None: + return self + + required_fields = BMS_REQUIRED_FIELDS.get(chat_bubble_type, []) + missing_fields = [ + field for field in required_fields if getattr(self, field, None) is None + ] + + if missing_fields: + camel_fields = [_to_camel(f) for f in missing_fields] + raise ValueError( + f"BMS {chat_bubble_type} 타입에 필수 필드가 누락되었습니다: " + f"{', '.join(camel_fields)}" + ) + + if chat_bubble_type == "WIDE_ITEM_LIST": + sub_wide_item_list = self.sub_wide_item_list + if ( + not sub_wide_item_list + or len(sub_wide_item_list) < WIDE_ITEM_LIST_MIN_SUB_ITEMS + ): + raise ValueError( + f"WIDE_ITEM_LIST 타입의 subWideItemList는 최소 " + f"{WIDE_ITEM_LIST_MIN_SUB_ITEMS}개 이상이어야 합니다. " + f"현재: {len(sub_wide_item_list) if sub_wide_item_list else 0}개" + ) + + return self diff --git a/solapi/model/request/message.py b/solapi/model/request/message.py index 793edc1..c5fa214 100644 --- a/solapi/model/request/message.py +++ b/solapi/model/request/message.py @@ -81,4 +81,5 @@ def normalize_to_phone_number( extra="ignore", populate_by_name=True, alias_generator=to_camel, + use_enum_values=True, ) diff --git a/solapi/model/request/storage.py b/solapi/model/request/storage.py index e46a657..dff6bbd 100644 --- a/solapi/model/request/storage.py +++ b/solapi/model/request/storage.py @@ -9,6 +9,13 @@ class FileTypeEnum(str, Enum): KAKAO = "KAKAO" RCS = "RCS" FAX = "FAX" + # BMS (Brand Message Service) file types + BMS = "BMS" + BMS_WIDE = "BMS_WIDE" + BMS_WIDE_MAIN_ITEM_LIST = "BMS_WIDE_MAIN_ITEM_LIST" + BMS_WIDE_SUB_ITEM_LIST = "BMS_WIDE_SUB_ITEM_LIST" + BMS_CAROUSEL_FEED_LIST = "BMS_CAROUSEL_FEED_LIST" + BMS_CAROUSEL_COMMERCE_LIST = "BMS_CAROUSEL_COMMERCE_LIST" class FileUploadRequest(BaseModel): diff --git a/tests/test_bms_free.py b/tests/test_bms_free.py new file mode 100644 index 0000000..35a27db --- /dev/null +++ b/tests/test_bms_free.py @@ -0,0 +1,903 @@ +import pytest + +from solapi.model.kakao.bms import ( + BmsAppButton, + BmsCarouselCommerceItem, + BmsCarouselCommerceSchema, + BmsCarouselFeedItem, + BmsCarouselFeedSchema, + BmsCommerce, + BmsCoupon, + BmsMainWideItem, + BmsOption, + BmsSubWideItem, + BmsVideo, + BmsWebButton, +) +from solapi.model.request.kakao.bms import Bms + + +class TestBmsCommerce: + def test_valid_regular_price_only(self): + commerce = BmsCommerce(title="상품명", regular_price=10000) + assert commerce.title == "상품명" + assert commerce.regular_price == 10000 + + def test_valid_discount_rate(self): + commerce = BmsCommerce( + title="상품명", + regular_price=10000, + discount_price=8000, + discount_rate=20, + ) + assert commerce.discount_rate == 20 + + def test_valid_discount_fixed(self): + commerce = BmsCommerce( + title="상품명", + regular_price=10000, + discount_price=8000, + discount_fixed=2000, + ) + assert commerce.discount_fixed == 2000 + + def test_invalid_both_discount_types(self): + with pytest.raises(ValueError, match="discountRate와 discountFixed는 동시에"): + BmsCommerce( + title="상품명", + regular_price=10000, + discount_price=8000, + discount_rate=20, + discount_fixed=2000, + ) + + def test_invalid_discount_rate_without_price(self): + with pytest.raises(ValueError, match="discountPrice.*함께 지정"): + BmsCommerce( + title="상품명", + regular_price=10000, + discount_rate=20, + ) + + def test_invalid_discount_price_alone(self): + with pytest.raises(ValueError, match="discountRate.*discountFixed.*함께"): + BmsCommerce( + title="상품명", + regular_price=10000, + discount_price=8000, + ) + + def test_string_to_int_coercion(self): + commerce = BmsCommerce(title="상품명", regular_price="10000") # type: ignore[arg-type] + assert commerce.regular_price == 10000 + + +class TestBmsCoupon: + def test_valid_won_discount(self): + coupon = BmsCoupon(title="5000원 할인 쿠폰", description="설명") + assert coupon.title == "5000원 할인 쿠폰" + + def test_valid_percent_discount(self): + coupon = BmsCoupon(title="10% 할인 쿠폰", description="설명") + assert coupon.title == "10% 할인 쿠폰" + + def test_valid_shipping_discount(self): + coupon = BmsCoupon(title="배송비 할인 쿠폰", description="설명") + assert coupon.title == "배송비 할인 쿠폰" + + def test_valid_free_coupon(self): + coupon = BmsCoupon(title="커피 무료 쿠폰", description="설명") + assert coupon.title == "커피 무료 쿠폰" + + def test_valid_up_coupon(self): + coupon = BmsCoupon(title="포인트 UP 쿠폰", description="설명") + assert coupon.title == "포인트 UP 쿠폰" + + def test_invalid_coupon_title(self): + with pytest.raises(ValueError, match="쿠폰 제목은 다음 형식"): + BmsCoupon(title="잘못된 쿠폰", description="설명") + + +class TestBmsVideo: + def test_valid_kakao_tv_url(self): + video = BmsVideo(video_url="https://tv.kakao.com/v/123456") + assert video.video_url == "https://tv.kakao.com/v/123456" + + def test_invalid_url(self): + with pytest.raises(ValueError, match="카카오TV 동영상 링크"): + BmsVideo(video_url="https://youtube.com/watch?v=123") + + +class TestBmsButton: + def test_web_button(self): + button = BmsWebButton(name="버튼", link_mobile="https://example.com") + assert button.link_type == "WL" + assert button.name == "버튼" + + def test_app_button_with_mobile(self): + button = BmsAppButton(name="앱 버튼", link_mobile="https://example.com") + assert button.link_type == "AL" + + def test_app_button_with_android(self): + button = BmsAppButton(name="앱 버튼", link_android="app://path") + assert button.link_android == "app://path" + + def test_app_button_without_links(self): + with pytest.raises( + ValueError, match="linkMobile, linkAndroid, linkIos 중 하나" + ): + BmsAppButton(name="앱 버튼") + + +class TestBmsWideItem: + def test_main_wide_item(self): + item = BmsMainWideItem(image_id="img123", link_mobile="https://example.com") + assert item.image_id == "img123" + assert item.title is None + + def test_sub_wide_item(self): + item = BmsSubWideItem( + title="서브 아이템", + image_id="img123", + link_mobile="https://example.com", + ) + assert item.title == "서브 아이템" + + +class TestBmsCarousel: + def test_feed_schema(self): + items = [ + BmsCarouselFeedItem( + header="헤더1", + content="내용1", + image_id="img1", + buttons=[BmsWebButton(name="버튼", link_mobile="https://example.com")], + ), + BmsCarouselFeedItem( + header="헤더2", + content="내용2", + image_id="img2", + buttons=[BmsWebButton(name="버튼", link_mobile="https://example.com")], + ), + ] + schema = BmsCarouselFeedSchema(items=items) + assert schema.items is not None + assert len(schema.items) == 2 + + def test_commerce_schema(self): + items = [ + BmsCarouselCommerceItem( + commerce=BmsCommerce(title="상품1", regular_price=10000), + image_id="img1", + buttons=[BmsWebButton(name="구매", link_mobile="https://example.com")], + ), + BmsCarouselCommerceItem( + commerce=BmsCommerce(title="상품2", regular_price=20000), + image_id="img2", + buttons=[BmsWebButton(name="구매", link_mobile="https://example.com")], + ), + ] + schema = BmsCarouselCommerceSchema(items=items) + assert schema.items is not None + assert len(schema.items) == 2 + + +class TestBmsOption: + def test_text_type_minimal(self): + bms = BmsOption(targeting="I", chat_bubble_type="TEXT") + assert bms.targeting == "I" + assert bms.chat_bubble_type == "TEXT" + + def test_image_type_requires_image_id(self): + with pytest.raises(ValueError, match="imageId"): + BmsOption(targeting="I", chat_bubble_type="IMAGE") + + def test_image_type_valid(self): + bms = BmsOption(targeting="I", chat_bubble_type="IMAGE", image_id="img123") + assert bms.image_id == "img123" + + def test_wide_type_requires_image_id(self): + with pytest.raises(ValueError, match="imageId"): + BmsOption(targeting="I", chat_bubble_type="WIDE") + + def test_wide_item_list_requires_minimum_sub_items(self): + main_item = BmsMainWideItem(image_id="img", link_mobile="https://example.com") + sub_items = [ + BmsSubWideItem( + title="1", image_id="img1", link_mobile="https://example.com" + ), + BmsSubWideItem( + title="2", image_id="img2", link_mobile="https://example.com" + ), + ] + with pytest.raises(ValueError, match="최소 3개"): + BmsOption( + targeting="I", + chat_bubble_type="WIDE_ITEM_LIST", + header="헤더", + main_wide_item=main_item, + sub_wide_item_list=sub_items, + ) + + def test_wide_item_list_valid(self): + main_item = BmsMainWideItem(image_id="img", link_mobile="https://example.com") + sub_items = [ + BmsSubWideItem( + title="1", image_id="img1", link_mobile="https://example.com" + ), + BmsSubWideItem( + title="2", image_id="img2", link_mobile="https://example.com" + ), + BmsSubWideItem( + title="3", image_id="img3", link_mobile="https://example.com" + ), + ] + bms = BmsOption( + targeting="I", + chat_bubble_type="WIDE_ITEM_LIST", + header="헤더", + main_wide_item=main_item, + sub_wide_item_list=sub_items, + ) + assert bms.sub_wide_item_list is not None + assert len(bms.sub_wide_item_list) == 3 + + def test_commerce_requires_fields(self): + with pytest.raises(ValueError, match="imageId.*commerce.*buttons"): + BmsOption(targeting="I", chat_bubble_type="COMMERCE") + + def test_commerce_valid(self): + bms = BmsOption( + targeting="I", + chat_bubble_type="COMMERCE", + image_id="img123", + commerce=BmsCommerce(title="상품", regular_price=10000), + buttons=[BmsWebButton(name="구매", link_mobile="https://example.com")], + ) + assert bms.commerce is not None + assert bms.commerce.title == "상품" + + def test_carousel_feed_requires_carousel(self): + with pytest.raises(ValueError, match="carousel"): + BmsOption(targeting="I", chat_bubble_type="CAROUSEL_FEED") + + def test_premium_video_requires_video(self): + with pytest.raises(ValueError, match="video"): + BmsOption(targeting="I", chat_bubble_type="PREMIUM_VIDEO") + + def test_premium_video_valid(self): + bms = BmsOption( + targeting="I", + chat_bubble_type="PREMIUM_VIDEO", + video=BmsVideo(video_url="https://tv.kakao.com/v/123"), + ) + assert bms.video is not None + assert bms.video.video_url == "https://tv.kakao.com/v/123" + + +class TestBms: + def test_bms_without_chat_bubble_type(self): + bms = Bms(targeting="I") + assert bms.targeting == "I" + assert bms.chat_bubble_type is None + + def test_bms_with_text_type(self): + bms = Bms(targeting="I", chat_bubble_type="TEXT") + assert bms.chat_bubble_type == "TEXT" + + def test_bms_serialization(self): + bms = Bms( + targeting="I", + chat_bubble_type="TEXT", + additional_content="추가 내용", + ) + data = bms.model_dump(by_alias=True, exclude_none=True) + assert data["targeting"] == "I" + assert data["chatBubbleType"] == "TEXT" + assert data["additionalContent"] == "추가 내용" + + +class TestBmsFreeE2E: + """E2E tests for BMS Free message sending. + + These tests actually send messages through the SOLAPI API. + Requires SOLAPI_KAKAO_PF_ID environment variable to be set. + """ + + def test_send_bms_text_minimal( + self, message_service, test_phone_numbers, test_kakao_options + ): + """Test sending BMS FREE TEXT type with minimal structure.""" + from solapi.model import RequestMessage + from solapi.model.kakao.kakao_option import KakaoOption + from solapi.model.message_type import MessageType + from solapi.model.request.kakao.bms import Bms + from solapi.model.response.send_message_response import SendMessageResponse + + pf_id = test_kakao_options.get("pf_id", "") + if not pf_id or pf_id == "계정에 등록된 카카오 비즈니스 채널ID": + pytest.skip("SOLAPI_KAKAO_PF_ID not configured") + + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트] BMS FREE TEXT 최소 구조 테스트입니다.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id=pf_id, + bms=Bms(targeting="I", chat_bubble_type="TEXT"), + ), + ) + + try: + response = message_service.send(message) + + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + except Exception as e: + pytest.skip(f"BMS FREE TEXT test skipped: {e}") + + def test_send_bms_text_with_buttons( + self, message_service, test_phone_numbers, test_kakao_options + ): + """Test sending BMS FREE TEXT type with buttons and coupon.""" + from solapi.model import RequestMessage + from solapi.model.kakao.kakao_option import KakaoOption + from solapi.model.message_type import MessageType + from solapi.model.request.kakao.bms import Bms + from solapi.model.response.send_message_response import SendMessageResponse + + pf_id = test_kakao_options.get("pf_id", "") + if not pf_id or pf_id == "계정에 등록된 카카오 비즈니스 채널ID": + pytest.skip("SOLAPI_KAKAO_PF_ID not configured") + + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트] BMS FREE TEXT 전체 필드 테스트입니다.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id=pf_id, + bms=Bms( + targeting="I", + chat_bubble_type="TEXT", + adult=False, + buttons=[ + BmsWebButton(name="웹 링크", link_mobile="https://example.com"), + BmsAppButton( + name="앱 링크", + link_mobile="https://example.com", + link_android="exampleapp://path", + link_ios="exampleapp://path", + ), + ], + coupon=BmsCoupon( + title="10% 할인 쿠폰", + description="테스트 쿠폰입니다.", + link_mobile="https://example.com/coupon", + ), + ), + ), + ) + + try: + response = message_service.send(message) + + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + except Exception as e: + pytest.skip(f"BMS FREE TEXT with buttons test skipped: {e}") + + def test_send_bms_image( + self, message_service, test_phone_numbers, test_kakao_options + ): + """Test sending BMS FREE IMAGE type with image upload.""" + from pathlib import Path + + from solapi.model import RequestMessage + from solapi.model.kakao.kakao_option import KakaoOption + from solapi.model.message_type import MessageType + from solapi.model.request.kakao.bms import Bms + from solapi.model.request.storage import FileTypeEnum + from solapi.model.response.send_message_response import SendMessageResponse + + pf_id = test_kakao_options.get("pf_id", "") + if not pf_id or pf_id == "계정에 등록된 카카오 비즈니스 채널ID": + pytest.skip("SOLAPI_KAKAO_PF_ID not configured") + + image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example.jpg" + ) + if not image_path.exists(): + pytest.skip(f"Test image not found at {image_path}") + + try: + file_response = message_service.upload_file( + file_path=str(image_path), + upload_type=FileTypeEnum.BMS, + ) + image_id = file_response.file_id + print(f"Uploaded BMS image ID: {image_id}") + + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트] BMS FREE IMAGE 테스트입니다.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id=pf_id, + bms=Bms( + targeting="I", + chat_bubble_type="IMAGE", + image_id=image_id, + ), + ), + ) + + response = message_service.send(message) + + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + except Exception as e: + pytest.skip(f"BMS FREE IMAGE test skipped: {e}") + + def test_send_bms_commerce( + self, message_service, test_phone_numbers, test_kakao_options + ): + """Test sending BMS FREE COMMERCE type with product info.""" + from pathlib import Path + + from solapi.model import RequestMessage + from solapi.model.kakao.kakao_option import KakaoOption + from solapi.model.message_type import MessageType + from solapi.model.request.kakao.bms import Bms + from solapi.model.request.storage import FileTypeEnum + from solapi.model.response.send_message_response import SendMessageResponse + + pf_id = test_kakao_options.get("pf_id", "") + if not pf_id or pf_id == "계정에 등록된 카카오 비즈니스 채널ID": + pytest.skip("SOLAPI_KAKAO_PF_ID not configured") + + image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example.jpg" + ) + if not image_path.exists(): + pytest.skip(f"Test image not found at {image_path}") + + try: + file_response = message_service.upload_file( + file_path=str(image_path), + upload_type=FileTypeEnum.BMS, + ) + image_id = file_response.file_id + + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id=pf_id, + bms=Bms( + targeting="I", + chat_bubble_type="COMMERCE", + image_id=image_id, + commerce=BmsCommerce( + title="테스트 상품", + regular_price=50000, + discount_price=40000, + discount_rate=20, + ), + buttons=[ + BmsWebButton( + name="구매하기", + link_mobile="https://example.com/product", + ), + ], + ), + ), + ) + + response = message_service.send(message) + + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + except Exception as e: + pytest.skip(f"BMS FREE COMMERCE test skipped: {e}") + + def test_send_bms_wide( + self, message_service, test_phone_numbers, test_kakao_options + ): + """Test sending BMS FREE WIDE type.""" + from pathlib import Path + + from solapi.model import RequestMessage + from solapi.model.kakao.kakao_option import KakaoOption + from solapi.model.message_type import MessageType + from solapi.model.request.kakao.bms import Bms + from solapi.model.request.storage import FileTypeEnum + from solapi.model.response.send_message_response import SendMessageResponse + + pf_id = test_kakao_options.get("pf_id", "") + if not pf_id or pf_id == "계정에 등록된 카카오 비즈니스 채널ID": + pytest.skip("SOLAPI_KAKAO_PF_ID not configured") + + image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example.jpg" + ) + if not image_path.exists(): + pytest.skip(f"Test image not found at {image_path}") + + try: + file_response = message_service.upload_file( + file_path=str(image_path), + upload_type=FileTypeEnum.BMS_WIDE, + ) + image_id = file_response.file_id + print(f"Uploaded BMS WIDE image ID: {image_id}") + + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트] BMS FREE WIDE 테스트입니다.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id=pf_id, + bms=Bms( + targeting="I", + chat_bubble_type="WIDE", + image_id=image_id, + buttons=[ + BmsWebButton( + name="자세히 보기", + link_mobile="https://example.com", + ), + ], + ), + ), + ) + + response = message_service.send(message) + + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + except Exception as e: + pytest.skip(f"BMS FREE WIDE test skipped: {e}") + + def test_send_bms_wide_item_list( + self, message_service, test_phone_numbers, test_kakao_options + ): + """Test sending BMS FREE WIDE_ITEM_LIST type.""" + from pathlib import Path + + from solapi.model import RequestMessage + from solapi.model.kakao.kakao_option import KakaoOption + from solapi.model.message_type import MessageType + from solapi.model.request.kakao.bms import Bms + from solapi.model.request.storage import FileTypeEnum + from solapi.model.response.send_message_response import SendMessageResponse + + pf_id = test_kakao_options.get("pf_id", "") + if not pf_id or pf_id == "계정에 등록된 카카오 비즈니스 채널ID": + pytest.skip("SOLAPI_KAKAO_PF_ID not configured") + + image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example.jpg" + ) + if not image_path.exists(): + pytest.skip(f"Test image not found at {image_path}") + + try: + main_file_response = message_service.upload_file( + file_path=str(image_path), + upload_type=FileTypeEnum.BMS_WIDE_MAIN_ITEM_LIST, + ) + main_image_id = main_file_response.file_id + print(f"Uploaded main image ID: {main_image_id}") + + sub_file_response = message_service.upload_file( + file_path=str(image_path), + upload_type=FileTypeEnum.BMS_WIDE_SUB_ITEM_LIST, + ) + sub_image_id = sub_file_response.file_id + print(f"Uploaded sub image ID: {sub_image_id}") + + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id=pf_id, + bms=Bms( + targeting="I", + chat_bubble_type="WIDE_ITEM_LIST", + header="와이드 아이템 리스트 테스트", + main_wide_item=BmsMainWideItem( + image_id=main_image_id, + title="메인 아이템", + link_mobile="https://example.com/main", + ), + sub_wide_item_list=[ + BmsSubWideItem( + image_id=sub_image_id, + title="서브 아이템 1", + link_mobile="https://example.com/sub1", + ), + BmsSubWideItem( + image_id=sub_image_id, + title="서브 아이템 2", + link_mobile="https://example.com/sub2", + ), + BmsSubWideItem( + image_id=sub_image_id, + title="서브 아이템 3", + link_mobile="https://example.com/sub3", + ), + ], + buttons=[ + BmsWebButton( + name="더보기", + link_mobile="https://example.com", + ), + ], + ), + ), + ) + + response = message_service.send(message) + + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + except Exception as e: + pytest.skip(f"BMS FREE WIDE_ITEM_LIST test skipped: {e}") + + def test_send_bms_carousel_feed( + self, message_service, test_phone_numbers, test_kakao_options + ): + """Test sending BMS FREE CAROUSEL_FEED type.""" + from pathlib import Path + + from solapi.model import RequestMessage + from solapi.model.kakao.kakao_option import KakaoOption + from solapi.model.message_type import MessageType + from solapi.model.request.kakao.bms import Bms + from solapi.model.request.storage import FileTypeEnum + from solapi.model.response.send_message_response import SendMessageResponse + + pf_id = test_kakao_options.get("pf_id", "") + if not pf_id or pf_id == "계정에 등록된 카카오 비즈니스 채널ID": + pytest.skip("SOLAPI_KAKAO_PF_ID not configured") + + image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example.jpg" + ) + if not image_path.exists(): + pytest.skip(f"Test image not found at {image_path}") + + try: + file_response = message_service.upload_file( + file_path=str(image_path), + upload_type=FileTypeEnum.BMS_CAROUSEL_FEED_LIST, + ) + image_id = file_response.file_id + print(f"Uploaded carousel feed image ID: {image_id}") + + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id=pf_id, + bms=Bms( + targeting="I", + chat_bubble_type="CAROUSEL_FEED", + carousel=BmsCarouselFeedSchema( + items=[ + BmsCarouselFeedItem( + header="첫 번째 카드", + content="캐러셀 피드 테스트 메시지입니다.", + image_id=image_id, + buttons=[ + BmsWebButton( + name="자세히 보기", + link_mobile="https://example.com/1", + ), + ], + ), + BmsCarouselFeedItem( + header="두 번째 카드", + content="두 번째 캐러셀 아이템입니다.", + image_id=image_id, + buttons=[ + BmsWebButton( + name="자세히 보기", + link_mobile="https://example.com/2", + ), + ], + ), + ], + ), + ), + ), + ) + + response = message_service.send(message) + + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + except Exception as e: + pytest.skip(f"BMS FREE CAROUSEL_FEED test skipped: {e}") + + def test_send_bms_carousel_commerce( + self, message_service, test_phone_numbers, test_kakao_options + ): + """Test sending BMS FREE CAROUSEL_COMMERCE type.""" + from pathlib import Path + + from solapi.model import RequestMessage + from solapi.model.kakao.kakao_option import KakaoOption + from solapi.model.message_type import MessageType + from solapi.model.request.kakao.bms import Bms + from solapi.model.request.storage import FileTypeEnum + from solapi.model.response.send_message_response import SendMessageResponse + + pf_id = test_kakao_options.get("pf_id", "") + if not pf_id or pf_id == "계정에 등록된 카카오 비즈니스 채널ID": + pytest.skip("SOLAPI_KAKAO_PF_ID not configured") + + image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example.jpg" + ) + if not image_path.exists(): + pytest.skip(f"Test image not found at {image_path}") + + try: + file_response = message_service.upload_file( + file_path=str(image_path), + upload_type=FileTypeEnum.BMS_CAROUSEL_COMMERCE_LIST, + ) + image_id = file_response.file_id + print(f"Uploaded carousel commerce image ID: {image_id}") + + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id=pf_id, + bms=Bms( + targeting="I", + chat_bubble_type="CAROUSEL_COMMERCE", + carousel=BmsCarouselCommerceSchema( + items=[ + BmsCarouselCommerceItem( + image_id=image_id, + commerce=BmsCommerce( + title="상품 1", + regular_price=50000, + discount_price=40000, + discount_rate=20, + ), + buttons=[ + BmsWebButton( + name="구매하기", + link_mobile="https://example.com/product1", + ), + ], + ), + BmsCarouselCommerceItem( + image_id=image_id, + commerce=BmsCommerce( + title="상품 2", + regular_price=80000, + discount_price=60000, + discount_fixed=20000, + ), + buttons=[ + BmsWebButton( + name="구매하기", + link_mobile="https://example.com/product2", + ), + ], + ), + ], + ), + ), + ), + ) + + response = message_service.send(message) + + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + except Exception as e: + pytest.skip(f"BMS FREE CAROUSEL_COMMERCE test skipped: {e}") + + def test_send_bms_premium_video( + self, message_service, test_phone_numbers, test_kakao_options + ): + """Test sending BMS FREE PREMIUM_VIDEO type.""" + from solapi.model import RequestMessage + from solapi.model.kakao.kakao_option import KakaoOption + from solapi.model.message_type import MessageType + from solapi.model.request.kakao.bms import Bms + from solapi.model.response.send_message_response import SendMessageResponse + + pf_id = test_kakao_options.get("pf_id", "") + if not pf_id or pf_id == "계정에 등록된 카카오 비즈니스 채널ID": + pytest.skip("SOLAPI_KAKAO_PF_ID not configured") + + try: + message = RequestMessage( + from_=test_phone_numbers["sender"], + to=test_phone_numbers["recipient"], + text="[테스트] BMS FREE PREMIUM_VIDEO 테스트입니다.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id=pf_id, + bms=Bms( + targeting="I", + chat_bubble_type="PREMIUM_VIDEO", + video=BmsVideo( + video_url="https://tv.kakao.com/v/123456789", + ), + buttons=[ + BmsWebButton( + name="영상 보기", + link_mobile="https://tv.kakao.com/v/123456789", + ), + ], + ), + ), + ) + + response = message_service.send(message) + + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + except Exception as e: + pytest.skip(f"BMS FREE PREMIUM_VIDEO test skipped: {e}") From c6bcc8d9df7f58fa50bff2ed9cc65ae37598b189 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 14:32:12 +0900 Subject: [PATCH 53/60] Update solapi package version to 5.0.3 in uv.lock --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index dc4604e..71b8abc 100644 --- a/uv.lock +++ b/uv.lock @@ -375,7 +375,7 @@ wheels = [ [[package]] name = "solapi" -version = "5.0.2" +version = "5.0.3" source = { editable = "." } dependencies = [ { name = "httpx" }, From 27c8d0ca27b56b15163b7d179fd55e42c49fb2dd Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 14:33:37 +0900 Subject: [PATCH 54/60] Update version to 5.0.3 in AGENTS.md and related files --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index e2dec6b..bffc9c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ model/ ### VERSION SYNC REQUIRED ```python # solapi/model/request/__init__.py -VERSION = "python/5.0.2" # MUST update on every release! +VERSION = "python/5.0.3" # MUST update on every release! ``` Also update `pyproject.toml` version. From 726e1639c9e73f0eeba78d699380d3c011e32099 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 14:33:59 +0900 Subject: [PATCH 55/60] Update version to 5.0.3 in AGENTS.md and request module --- solapi/model/AGENTS.md | 2 +- solapi/model/request/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/solapi/model/AGENTS.md b/solapi/model/AGENTS.md index d39ebe2..a8e8daf 100644 --- a/solapi/model/AGENTS.md +++ b/solapi/model/AGENTS.md @@ -90,7 +90,7 @@ image_id: Optional[str] = Field(default=None, alias="imageId") ### VERSION IN THIS PACKAGE ```python # request/__init__.py line 1-2 -VERSION = "python/5.0.2" # Sync with pyproject.toml! +VERSION = "python/5.0.3" # Sync with pyproject.toml! ``` ## KEY CLASSES diff --git a/solapi/model/request/__init__.py b/solapi/model/request/__init__.py index 0fe91e2..7d920d8 100644 --- a/solapi/model/request/__init__.py +++ b/solapi/model/request/__init__.py @@ -1,2 +1,2 @@ # NOTE: Python SDK가 업데이트 될 때마다 Version도 갱신해야 함! -VERSION = "python/5.0.2" +VERSION = "python/5.0.3" From bee47b7446713510ec9289b97c8cd63c9852a834 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 15:18:37 +0900 Subject: [PATCH 56/60] Enhance BMS functionality and add new images - Added support for development requirements in `uv.lock`. - Introduced new images: `example_square.jpg` and `example_wide.jpg`. - Refactored BMS models to use `list` instead of `List` for type hints. - Improved validation logic in `BmsCommerce` for discount handling. - Updated tests to ensure proper assertions and added image checks for BMS wide item list. --- examples/images/example_square.jpg | Bin 0 -> 18009 bytes examples/images/example_wide.jpg | Bin 0 -> 25983 bytes solapi/model/kakao/bms/bms_carousel.py | 14 +-- solapi/model/kakao/bms/bms_commerce.py | 38 +++--- solapi/model/request/kakao/bms.py | 35 +----- tests/test_bms_free.py | 166 +++++++++++++------------ uv.lock | 3 + 7 files changed, 118 insertions(+), 138 deletions(-) create mode 100644 examples/images/example_square.jpg create mode 100644 examples/images/example_wide.jpg diff --git a/examples/images/example_square.jpg b/examples/images/example_square.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8598047f0b24592175617f1758fd5ad72a75930d GIT binary patch literal 18009 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 MuHQ^N*!jf&17Uwa+W-In literal 0 HcmV?d00001 diff --git a/examples/images/example_wide.jpg b/examples/images/example_wide.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f7af943848c3ef4be0cb7a747e90ef842a7b09f4 GIT binary patch literal 25983 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 SpQsC|Uhw<}0E_l;=zjojG9YIF literal 0 HcmV?d00001 diff --git a/solapi/model/kakao/bms/bms_carousel.py b/solapi/model/kakao/bms/bms_carousel.py index f8b0d9c..7e129b9 100644 --- a/solapi/model/kakao/bms/bms_carousel.py +++ b/solapi/model/kakao/bms/bms_carousel.py @@ -1,14 +1,12 @@ -from typing import List, Optional, Union +from typing import Optional from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel -from solapi.model.kakao.bms.bms_button import BmsAppButton, BmsWebButton +from solapi.model.kakao.bms.bms_button import BmsLinkButton from solapi.model.kakao.bms.bms_commerce import BmsCommerce from solapi.model.kakao.bms.bms_coupon import BmsCoupon -BmsLinkButton = Union[BmsWebButton, BmsAppButton] - class BmsCarouselHead(BaseModel): header: Optional[str] = None @@ -36,14 +34,14 @@ class BmsCarouselFeedItem(BaseModel): content: Optional[str] = None image_id: Optional[str] = None image_link: Optional[str] = None - buttons: Optional[List[BmsLinkButton]] = None + buttons: Optional[list[BmsLinkButton]] = None coupon: Optional[BmsCoupon] = None model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) class BmsCarouselFeedSchema(BaseModel): - items: Optional[List[BmsCarouselFeedItem]] = Field(default=None, alias="list") + items: Optional[list[BmsCarouselFeedItem]] = Field(default=None, alias="list") tail: Optional[BmsCarouselTail] = None model_config = ConfigDict(populate_by_name=True) @@ -53,7 +51,7 @@ class BmsCarouselCommerceItem(BaseModel): commerce: Optional[BmsCommerce] = None image_id: Optional[str] = None image_link: Optional[str] = None - buttons: Optional[List[BmsLinkButton]] = None + buttons: Optional[list[BmsLinkButton]] = None additional_content: Optional[str] = None coupon: Optional[BmsCoupon] = None @@ -62,7 +60,7 @@ class BmsCarouselCommerceItem(BaseModel): class BmsCarouselCommerceSchema(BaseModel): head: Optional[BmsCarouselHead] = None - items: Optional[List[BmsCarouselCommerceItem]] = Field(default=None, alias="list") + items: Optional[list[BmsCarouselCommerceItem]] = Field(default=None, alias="list") tail: Optional[BmsCarouselTail] = None model_config = ConfigDict(populate_by_name=True) diff --git a/solapi/model/kakao/bms/bms_commerce.py b/solapi/model/kakao/bms/bms_commerce.py index d6d72f4..aee583f 100644 --- a/solapi/model/kakao/bms/bms_commerce.py +++ b/solapi/model/kakao/bms/bms_commerce.py @@ -40,34 +40,28 @@ def validate_price_combination(self) -> "BmsCommerce": has_discount_rate = self.discount_rate is not None has_discount_fixed = self.discount_fixed is not None - if not has_discount_price and not has_discount_rate and not has_discount_fixed: - return self - - if has_discount_price and has_discount_rate and not has_discount_fixed: - return self - - if has_discount_price and has_discount_fixed and not has_discount_rate: + # 할인 정보 없음 = 유효 + if not any([has_discount_price, has_discount_rate, has_discount_fixed]): return self + # discountRate와 discountFixed는 상호 배타적 if has_discount_rate and has_discount_fixed: raise ValueError( "discountRate와 discountFixed는 동시에 사용할 수 없습니다. " "할인율(discountRate) 또는 정액할인(discountFixed) 중 하나만 선택하세요." ) - if not has_discount_price and (has_discount_rate or has_discount_fixed): - raise ValueError( - "discountRate 또는 discountFixed를 사용하려면 " - "discountPrice(할인가)도 함께 지정해야 합니다." - ) - - if has_discount_price and not has_discount_rate and not has_discount_fixed: - raise ValueError( - "discountPrice를 사용하려면 discountRate(할인율) 또는 " - "discountFixed(정액할인) 중 하나를 함께 지정해야 합니다." - ) + # 할인 정보는 완전한 세트여야 함 (discountPrice + discountRate/discountFixed) + if has_discount_price != (has_discount_rate or has_discount_fixed): + if has_discount_price: + raise ValueError( + "discountPrice를 사용하려면 discountRate(할인율) 또는 " + "discountFixed(정액할인) 중 하나를 함께 지정해야 합니다." + ) + else: + raise ValueError( + "discountRate 또는 discountFixed를 사용하려면 " + "discountPrice(할인가)도 함께 지정해야 합니다." + ) - raise ValueError( - "알 수 없는 가격 조합입니다. regularPrice만 사용하거나, " - "regularPrice + discountPrice + discountRate/discountFixed 조합을 사용하세요." - ) + return self diff --git a/solapi/model/request/kakao/bms.py b/solapi/model/request/kakao/bms.py index 6f59810..1bf605b 100644 --- a/solapi/model/request/kakao/bms.py +++ b/solapi/model/request/kakao/bms.py @@ -10,38 +10,15 @@ ) from solapi.model.kakao.bms.bms_commerce import BmsCommerce from solapi.model.kakao.bms.bms_coupon import BmsCoupon +from solapi.model.kakao.bms.bms_option import ( + BMS_REQUIRED_FIELDS, + WIDE_ITEM_LIST_MIN_SUB_ITEMS, + BmsChatBubbleType, + _to_camel, +) from solapi.model.kakao.bms.bms_video import BmsVideo from solapi.model.kakao.bms.bms_wide_item import BmsMainWideItem, BmsSubWideItem -BmsChatBubbleType = Literal[ - "TEXT", - "IMAGE", - "WIDE", - "WIDE_ITEM_LIST", - "COMMERCE", - "CAROUSEL_FEED", - "CAROUSEL_COMMERCE", - "PREMIUM_VIDEO", -] - -BMS_REQUIRED_FIELDS: dict[BmsChatBubbleType, list[str]] = { - "TEXT": [], - "IMAGE": ["image_id"], - "WIDE": ["image_id"], - "WIDE_ITEM_LIST": ["header", "main_wide_item", "sub_wide_item_list"], - "COMMERCE": ["image_id", "commerce", "buttons"], - "CAROUSEL_FEED": ["carousel"], - "CAROUSEL_COMMERCE": ["carousel"], - "PREMIUM_VIDEO": ["video"], -} - -WIDE_ITEM_LIST_MIN_SUB_ITEMS = 3 - - -def _to_camel(s: str) -> str: - components = s.split("_") - return components[0] + "".join(x.title() for x in components[1:]) - class Bms(BaseModel): targeting: Optional[Literal["I", "M", "N"]] = None diff --git a/tests/test_bms_free.py b/tests/test_bms_free.py index 35a27db..43afbe3 100644 --- a/tests/test_bms_free.py +++ b/tests/test_bms_free.py @@ -331,17 +331,17 @@ def test_send_bms_text_minimal( try: response = message_service.send(message) - - assert isinstance(response, SendMessageResponse) - assert response.group_info is not None - assert response.group_info.count.total > 0 - - print(f"Group ID: {response.group_info.group_id}") - print(f"Total: {response.group_info.count.total}") - print(f"Success: {response.group_info.count.registered_success}") except Exception as e: pytest.skip(f"BMS FREE TEXT test skipped: {e}") + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + def test_send_bms_text_with_buttons( self, message_service, test_phone_numbers, test_kakao_options ): @@ -387,17 +387,17 @@ def test_send_bms_text_with_buttons( try: response = message_service.send(message) - - assert isinstance(response, SendMessageResponse) - assert response.group_info is not None - assert response.group_info.count.total > 0 - - print(f"Group ID: {response.group_info.group_id}") - print(f"Total: {response.group_info.count.total}") - print(f"Success: {response.group_info.count.registered_success}") except Exception as e: pytest.skip(f"BMS FREE TEXT with buttons test skipped: {e}") + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + def test_send_bms_image( self, message_service, test_phone_numbers, test_kakao_options ): @@ -445,17 +445,17 @@ def test_send_bms_image( ) response = message_service.send(message) - - assert isinstance(response, SendMessageResponse) - assert response.group_info is not None - assert response.group_info.count.total > 0 - - print(f"Group ID: {response.group_info.group_id}") - print(f"Total: {response.group_info.count.total}") - print(f"Success: {response.group_info.count.registered_success}") except Exception as e: pytest.skip(f"BMS FREE IMAGE test skipped: {e}") + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + def test_send_bms_commerce( self, message_service, test_phone_numbers, test_kakao_options ): @@ -513,17 +513,17 @@ def test_send_bms_commerce( ) response = message_service.send(message) - - assert isinstance(response, SendMessageResponse) - assert response.group_info is not None - assert response.group_info.count.total > 0 - - print(f"Group ID: {response.group_info.group_id}") - print(f"Total: {response.group_info.count.total}") - print(f"Success: {response.group_info.count.registered_success}") except Exception as e: pytest.skip(f"BMS FREE COMMERCE test skipped: {e}") + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + def test_send_bms_wide( self, message_service, test_phone_numbers, test_kakao_options ): @@ -577,21 +577,24 @@ def test_send_bms_wide( ) response = message_service.send(message) - - assert isinstance(response, SendMessageResponse) - assert response.group_info is not None - assert response.group_info.count.total > 0 - - print(f"Group ID: {response.group_info.group_id}") - print(f"Total: {response.group_info.count.total}") - print(f"Success: {response.group_info.count.registered_success}") except Exception as e: pytest.skip(f"BMS FREE WIDE test skipped: {e}") + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + def test_send_bms_wide_item_list( self, message_service, test_phone_numbers, test_kakao_options ): - """Test sending BMS FREE WIDE_ITEM_LIST type.""" + """Test sending BMS FREE WIDE_ITEM_LIST type. + + Note: Main item requires 2:1 ratio, sub items require 1:1 ratio. + """ from pathlib import Path from solapi.model import RequestMessage @@ -605,22 +608,27 @@ def test_send_bms_wide_item_list( if not pf_id or pf_id == "계정에 등록된 카카오 비즈니스 채널ID": pytest.skip("SOLAPI_KAKAO_PF_ID not configured") - image_path = ( - Path(__file__).parent.parent / "examples" / "images" / "example.jpg" + main_image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example_wide.jpg" ) - if not image_path.exists(): - pytest.skip(f"Test image not found at {image_path}") + sub_image_path = ( + Path(__file__).parent.parent / "examples" / "images" / "example_square.jpg" + ) + if not main_image_path.exists(): + pytest.skip(f"2:1 ratio test image not found at {main_image_path}") + if not sub_image_path.exists(): + pytest.skip(f"1:1 ratio test image not found at {sub_image_path}") try: main_file_response = message_service.upload_file( - file_path=str(image_path), + file_path=str(main_image_path), upload_type=FileTypeEnum.BMS_WIDE_MAIN_ITEM_LIST, ) main_image_id = main_file_response.file_id print(f"Uploaded main image ID: {main_image_id}") sub_file_response = message_service.upload_file( - file_path=str(image_path), + file_path=str(sub_image_path), upload_type=FileTypeEnum.BMS_WIDE_SUB_ITEM_LIST, ) sub_image_id = sub_file_response.file_id @@ -669,17 +677,17 @@ def test_send_bms_wide_item_list( ) response = message_service.send(message) - - assert isinstance(response, SendMessageResponse) - assert response.group_info is not None - assert response.group_info.count.total > 0 - - print(f"Group ID: {response.group_info.group_id}") - print(f"Total: {response.group_info.count.total}") - print(f"Success: {response.group_info.count.registered_success}") except Exception as e: pytest.skip(f"BMS FREE WIDE_ITEM_LIST test skipped: {e}") + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + def test_send_bms_carousel_feed( self, message_service, test_phone_numbers, test_kakao_options ): @@ -751,17 +759,17 @@ def test_send_bms_carousel_feed( ) response = message_service.send(message) - - assert isinstance(response, SendMessageResponse) - assert response.group_info is not None - assert response.group_info.count.total > 0 - - print(f"Group ID: {response.group_info.group_id}") - print(f"Total: {response.group_info.count.total}") - print(f"Success: {response.group_info.count.registered_success}") except Exception as e: pytest.skip(f"BMS FREE CAROUSEL_FEED test skipped: {e}") + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + def test_send_bms_carousel_commerce( self, message_service, test_phone_numbers, test_kakao_options ): @@ -841,17 +849,17 @@ def test_send_bms_carousel_commerce( ) response = message_service.send(message) - - assert isinstance(response, SendMessageResponse) - assert response.group_info is not None - assert response.group_info.count.total > 0 - - print(f"Group ID: {response.group_info.group_id}") - print(f"Total: {response.group_info.count.total}") - print(f"Success: {response.group_info.count.registered_success}") except Exception as e: pytest.skip(f"BMS FREE CAROUSEL_COMMERCE test skipped: {e}") + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") + def test_send_bms_premium_video( self, message_service, test_phone_numbers, test_kakao_options ): @@ -891,13 +899,13 @@ def test_send_bms_premium_video( ) response = message_service.send(message) - - assert isinstance(response, SendMessageResponse) - assert response.group_info is not None - assert response.group_info.count.total > 0 - - print(f"Group ID: {response.group_info.group_id}") - print(f"Total: {response.group_info.count.total}") - print(f"Success: {response.group_info.count.registered_success}") except Exception as e: pytest.skip(f"BMS FREE PREMIUM_VIDEO test skipped: {e}") + + assert isinstance(response, SendMessageResponse) + assert response.group_info is not None + assert response.group_info.count.total > 0 + + print(f"Group ID: {response.group_info.group_id}") + print(f"Total: {response.group_info.count.total}") + print(f"Success: {response.group_info.count.registered_success}") diff --git a/uv.lock b/uv.lock index 71b8abc..735b56a 100644 --- a/uv.lock +++ b/uv.lock @@ -399,6 +399,9 @@ requires-dist = [ ] provides-extras = ["dev"] +[package.metadata.requires-dev] +dev = [] + [[package]] name = "sqlparse" version = "0.5.3" From c04acf092de187c6707652f1d63745f7bd968c34 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 15:42:04 +0900 Subject: [PATCH 57/60] Refactor BMS models and add new examples - Removed unused development requirements from `uv.lock`. - Introduced new example scripts for various BMS message types, including carousel commerce, carousel feed, and commerce messages. - Added validation for required fields in BMS models to enhance error handling. - Updated imports in BMS module to include new validation functions. - Improved documentation in example scripts for better clarity on usage. --- .../simple/send_bms_free_carousel_commerce.py | 121 ++++++++++++++++++ .../simple/send_bms_free_carousel_feed.py | 115 +++++++++++++++++ examples/simple/send_bms_free_commerce.py | 81 ++++++++++++ examples/simple/send_bms_free_image.py | 47 +++++++ .../send_bms_free_image_with_buttons.py | 76 +++++++++++ .../simple/send_bms_free_premium_video.py | 92 +++++++++++++ examples/simple/send_bms_free_text.py | 95 ++++++++++++++ .../simple/send_bms_free_text_with_buttons.py | 62 +++++++++ examples/simple/send_bms_free_wide.py | 54 ++++++++ .../simple/send_bms_free_wide_item_list.py | 84 ++++++++++++ solapi/model/kakao/bms/__init__.py | 8 +- solapi/model/kakao/bms/bms_option.py | 74 ++++++----- solapi/model/request/kakao/bms.py | 37 +----- uv.lock | 3 - 14 files changed, 883 insertions(+), 66 deletions(-) create mode 100644 examples/simple/send_bms_free_carousel_commerce.py create mode 100644 examples/simple/send_bms_free_carousel_feed.py create mode 100644 examples/simple/send_bms_free_commerce.py create mode 100644 examples/simple/send_bms_free_image.py create mode 100644 examples/simple/send_bms_free_image_with_buttons.py create mode 100644 examples/simple/send_bms_free_premium_video.py create mode 100644 examples/simple/send_bms_free_text.py create mode 100644 examples/simple/send_bms_free_text_with_buttons.py create mode 100644 examples/simple/send_bms_free_wide.py create mode 100644 examples/simple/send_bms_free_wide_item_list.py diff --git a/examples/simple/send_bms_free_carousel_commerce.py b/examples/simple/send_bms_free_carousel_commerce.py new file mode 100644 index 0000000..68fef1b --- /dev/null +++ b/examples/simple/send_bms_free_carousel_commerce.py @@ -0,0 +1,121 @@ +""" +카카오 BMS 자유형 CAROUSEL_COMMERCE 타입 발송 예제 +캐러셀 커머스 형식으로, 여러 상품을 슬라이드로 보여주는 구조입니다. +이미지 업로드 시 fileType은 'BMS_CAROUSEL_COMMERCE_LIST'를 사용해야 합니다. (2:1 비율 이미지 필수) +head + list(상품카드들) + tail 구조입니다. +head 없이 2-6개 아이템, head 포함 시 1-5개 아이템 가능합니다. +가격 정보(regularPrice, discountPrice, discountRate, discountFixed)는 숫자 타입입니다. +캐러셀 커머스 버튼은 WL, AL 타입만 지원합니다. +쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" +발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 +""" + +from os.path import abspath + +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage +from solapi.model.kakao.bms import ( + BmsAppButton, + BmsCarouselCommerceItem, + BmsCarouselCommerceSchema, + BmsCarouselHead, + BmsCarouselTail, + BmsCommerce, + BmsCoupon, + BmsWebButton, +) +from solapi.model.message_type import MessageType +from solapi.model.request.storage import FileTypeEnum + +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +try: + file_response = message_service.upload_file( + file_path=abspath("../images/example_wide.jpg"), + upload_type=FileTypeEnum.BMS_CAROUSEL_COMMERCE_LIST, + ) + image_id = file_response.file_id + print(f"파일 업로드 성공! File ID: {image_id}") + + message = RequestMessage( + from_="발신번호", + to="수신번호", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="CAROUSEL_COMMERCE", + adult=False, + additional_content="🔥 이번 주 한정 특가!", + carousel=BmsCarouselCommerceSchema( + head=BmsCarouselHead( + header="홍길동님을 위한 추천", + content="최근 관심 상품과 비슷한 아이템을 모았어요!", + image_id=image_id, + link_mobile="https://example.com/recommend", + ), + items=[ + BmsCarouselCommerceItem( + image_id=image_id, + commerce=BmsCommerce( + title="에어프라이어 대용량 5.5L", + regular_price=159000, + discount_price=119000, + discount_rate=25, + ), + additional_content="⚡ 무료배송", + image_link="https://example.com/airfryer", + buttons=[ + BmsWebButton( + name="지금 구매", + link_mobile="https://example.com", + link_pc="https://example.com", + ), + BmsAppButton( + name="앱에서 보기", + link_mobile="https://example.com", + link_android="examplescheme://path", + link_ios="examplescheme://path", + ), + ], + coupon=BmsCoupon( + title="10000원 할인 쿠폰", + description="첫 구매 고객 전용 쿠폰입니다.", + link_mobile="https://example.com/coupon", + ), + ), + BmsCarouselCommerceItem( + image_id=image_id, + commerce=BmsCommerce( + title="스마트 로봇청소기 프로", + regular_price=499000, + discount_price=399000, + discount_fixed=100000, + ), + buttons=[ + BmsWebButton( + name="상세 보기", + link_mobile="https://example.com", + link_pc="https://example.com", + ), + ], + ), + ], + tail=BmsCarouselTail( + link_mobile="https://example.com/all-products", + ), + ), + ), + ), + ) + + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") +except Exception as e: + print(f"발송 실패: {str(e)}") diff --git a/examples/simple/send_bms_free_carousel_feed.py b/examples/simple/send_bms_free_carousel_feed.py new file mode 100644 index 0000000..7709126 --- /dev/null +++ b/examples/simple/send_bms_free_carousel_feed.py @@ -0,0 +1,115 @@ +""" +카카오 BMS 자유형 CAROUSEL_FEED 타입 발송 예제 +캐러셀 피드 형식으로, 여러 카드를 좌우로 슬라이드하는 구조입니다. +이미지 업로드 시 fileType은 'BMS_CAROUSEL_FEED_LIST'를 사용해야 합니다. (2:1 비율 이미지 필수) +head 없이 2-6개 아이템, head 포함 시 1-5개 아이템 가능합니다. +캐러셀 피드 버튼은 WL, AL 타입만 지원합니다. +쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" +발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 +""" + +from os.path import abspath + +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage +from solapi.model.kakao.bms import ( + BmsAppButton, + BmsCarouselFeedItem, + BmsCarouselFeedSchema, + BmsCarouselTail, + BmsCoupon, + BmsWebButton, +) +from solapi.model.message_type import MessageType +from solapi.model.request.storage import FileTypeEnum + +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +try: + file_response = message_service.upload_file( + file_path=abspath("../images/example_wide.jpg"), + upload_type=FileTypeEnum.BMS_CAROUSEL_FEED_LIST, + ) + image_id = file_response.file_id + print(f"파일 업로드 성공! File ID: {image_id}") + + message = RequestMessage( + from_="발신번호", + to="수신번호", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="CAROUSEL_FEED", + adult=False, + carousel=BmsCarouselFeedSchema( + items=[ + BmsCarouselFeedItem( + header="🏃 마라톤 완주 도전!", + content="첫 마라톤 완주를 목표로 8주 트레이닝 프로그램을 시작해보세요.", + image_id=image_id, + image_link="https://example.com/marathon", + buttons=[ + BmsWebButton( + name="프로그램 신청", + link_mobile="https://example.com", + link_pc="https://example.com", + ), + BmsAppButton( + name="앱에서 보기", + link_mobile="https://example.com", + link_android="examplescheme://path", + link_ios="examplescheme://path", + ), + ], + coupon=BmsCoupon( + title="10% 할인 쿠폰", + description="첫 등록 고객 전용 할인 쿠폰입니다.", + link_mobile="https://example.com/coupon", + ), + ), + BmsCarouselFeedItem( + header="🧘 요가 입문 클래스", + content="초보자를 위한 기초 요가 동작을 배워보세요. 유연성과 마음의 평화를 함께!", + image_id=image_id, + buttons=[ + BmsWebButton( + name="클래스 보기", + link_mobile="https://example.com", + link_pc="https://example.com", + ), + ], + ), + BmsCarouselFeedItem( + header="💪 홈트레이닝 루틴", + content="장비 없이도 OK! 집에서 하는 30분 전신 운동 루틴.", + image_id=image_id, + buttons=[ + BmsAppButton( + name="영상 시청", + link_mobile="https://example.com", + link_android="examplescheme://path", + link_ios="examplescheme://path", + ), + ], + ), + ], + tail=BmsCarouselTail( + link_mobile="https://example.com/more", + link_pc="https://example.com/more", + ), + ), + ), + ), + ) + + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") +except Exception as e: + print(f"발송 실패: {str(e)}") diff --git a/examples/simple/send_bms_free_commerce.py b/examples/simple/send_bms_free_commerce.py new file mode 100644 index 0000000..24c1d24 --- /dev/null +++ b/examples/simple/send_bms_free_commerce.py @@ -0,0 +1,81 @@ +""" +카카오 BMS 자유형 COMMERCE 타입 발송 예제 +커머스(상품) 메시지로, 상품 이미지와 가격 정보, 쿠폰을 포함합니다. +이미지 업로드 시 fileType은 'BMS'를 사용해야 합니다. (2:1 비율 이미지 권장) +COMMERCE 타입은 buttons가 필수입니다 (최소 1개). +가격 정보(regularPrice, discountPrice, discountRate, discountFixed)는 숫자 타입입니다. +쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" +발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 +""" + +from os.path import abspath + +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage +from solapi.model.kakao.bms import ( + BmsAppButton, + BmsCommerce, + BmsCoupon, + BmsWebButton, +) +from solapi.model.message_type import MessageType +from solapi.model.request.storage import FileTypeEnum + +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +try: + file_response = message_service.upload_file( + file_path=abspath("../images/example_wide.jpg"), + upload_type=FileTypeEnum.BMS, + ) + print(f"파일 업로드 성공! File ID: {file_response.file_id}") + + message = RequestMessage( + from_="발신번호", + to="수신번호", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="COMMERCE", + adult=False, + additional_content="🚀 오늘 주문 시 내일 도착! 무료배송", + image_id=file_response.file_id, + commerce=BmsCommerce( + title="스마트 공기청정기 2024 신형", + regular_price=299000, + discount_price=209000, + discount_rate=30, + ), + buttons=[ + BmsWebButton( + name="지금 구매하기", + link_mobile="https://example.com", + link_pc="https://example.com", + ), + BmsAppButton( + name="앱에서 보기", + link_mobile="https://example.com", + link_android="examplescheme://path", + link_ios="examplescheme://path", + ), + ], + coupon=BmsCoupon( + title="포인트 UP 쿠폰", + description="구매 시 2배 적립 쿠폰입니다.", + link_mobile="https://example.com/coupon", + ), + ), + ), + ) + + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") +except Exception as e: + print(f"발송 실패: {str(e)}") diff --git a/examples/simple/send_bms_free_image.py b/examples/simple/send_bms_free_image.py new file mode 100644 index 0000000..68bc9fd --- /dev/null +++ b/examples/simple/send_bms_free_image.py @@ -0,0 +1,47 @@ +""" +카카오 BMS 자유형 IMAGE 타입 발송 예제 +이미지 업로드 후 imageId를 사용하여 발송합니다. +이미지 업로드 시 fileType은 반드시 'BMS'를 사용해야 합니다. +발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 +""" + +from os.path import abspath + +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage +from solapi.model.message_type import MessageType +from solapi.model.request.storage import FileTypeEnum + +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +try: + file_response = message_service.upload_file( + file_path=abspath("../images/example.jpg"), + upload_type=FileTypeEnum.BMS, + ) + print(f"파일 업로드 성공! File ID: {file_response.file_id}") + + message = RequestMessage( + from_="발신번호", + to="수신번호", + text="🆕 신상품이 입고되었어요!\n지금 바로 확인해보세요.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="IMAGE", + image_id=file_response.file_id, + ), + ), + ) + + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") +except Exception as e: + print(f"발송 실패: {str(e)}") diff --git a/examples/simple/send_bms_free_image_with_buttons.py b/examples/simple/send_bms_free_image_with_buttons.py new file mode 100644 index 0000000..eb16855 --- /dev/null +++ b/examples/simple/send_bms_free_image_with_buttons.py @@ -0,0 +1,76 @@ +""" +카카오 BMS 자유형 IMAGE 타입 + 버튼 발송 예제 +이미지 업로드 후 imageId를 사용하여 버튼과 함께 발송합니다. +이미지 업로드 시 fileType은 반드시 'BMS'를 사용해야 합니다. +BMS 자유형 버튼 타입: WL(웹링크), AL(앱링크), AC(채널추가), BK(봇키워드), MD(상담요청), BC(상담톡전환), BT(챗봇전환), BF(비즈니스폼) +쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" +발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 +""" + +from os.path import abspath + +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage +from solapi.model.kakao.bms import ( + BmsAppButton, + BmsChannelAddButton, + BmsCoupon, + BmsWebButton, +) +from solapi.model.message_type import MessageType +from solapi.model.request.storage import FileTypeEnum + +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +try: + file_response = message_service.upload_file( + file_path=abspath("../images/example.jpg"), + upload_type=FileTypeEnum.BMS, + ) + print(f"파일 업로드 성공! File ID: {file_response.file_id}") + + message = RequestMessage( + from_="발신번호", + to="수신번호", + text="🎁 연말 감사 이벤트!\n\n한 해 동안 함께해주셔서 감사합니다.\n특별한 혜택으로 보답드려요!", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="IMAGE", + adult=False, + image_id=file_response.file_id, + image_link="https://example.com/year-end-event", + buttons=[ + BmsWebButton( + name="이벤트 참여하기", + link_mobile="https://example.com", + link_pc="https://example.com", + ), + BmsAppButton( + name="앱에서 보기", + link_mobile="https://example.com", + link_android="examplescheme://path", + link_ios="examplescheme://path", + ), + BmsChannelAddButton(name="채널 추가"), + ], + coupon=BmsCoupon( + title="10000원 할인 쿠폰", + description="연말 감사 할인 쿠폰입니다.", + link_mobile="https://example.com/coupon", + ), + ), + ), + ) + + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") +except Exception as e: + print(f"발송 실패: {str(e)}") diff --git a/examples/simple/send_bms_free_premium_video.py b/examples/simple/send_bms_free_premium_video.py new file mode 100644 index 0000000..a2f0009 --- /dev/null +++ b/examples/simple/send_bms_free_premium_video.py @@ -0,0 +1,92 @@ +""" +카카오 BMS 자유형 PREMIUM_VIDEO 타입 발송 예제 +프리미엄 비디오 메시지로, 카카오TV 영상 URL과 썸네일 이미지를 포함합니다. +videoUrl은 반드시 "https://tv.kakao.com/"으로 시작해야 합니다. +유효하지 않은 동영상 URL 기입 시 발송 상태가 그룹 정보를 찾을 수 없음 오류로 표시됩니다. +쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" +발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 +""" + +from os.path import abspath + +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage +from solapi.model.kakao.bms import BmsCoupon, BmsVideo, BmsWebButton +from solapi.model.message_type import MessageType +from solapi.model.request.storage import FileTypeEnum + +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +message = RequestMessage( + from_="발신번호", + to="수신번호", + text="🎬 이번 시즌 인기 드라마 하이라이트!\n놓치신 분들을 위한 명장면 모음입니다.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="PREMIUM_VIDEO", + video=BmsVideo( + video_url="https://tv.kakao.com/v/460734285", + ), + ), + ), +) + +try: + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") +except Exception as e: + print(f"메시지 발송 실패: {str(e)}") + +try: + file_response = message_service.upload_file( + file_path=abspath("../images/example.jpg"), + upload_type=FileTypeEnum.KAKAO, + ) + + full_message = RequestMessage( + from_="발신번호", + to="수신번호", + text="🍿 주말 영화 추천!\n\n올해 가장 화제가 된 영화를 미리 만나보세요.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="PREMIUM_VIDEO", + adult=False, + header="🎥 이 주의 추천 영화", + content="2024년 최고의 액션 블록버스터! 지금 바로 예고편을 확인해보세요.", + video=BmsVideo( + video_url="https://tv.kakao.com/v/460734285", + image_id=file_response.file_id, + image_link="https://example.com/movie-trailer", + ), + buttons=[ + BmsWebButton( + name="예매하기", + link_mobile="https://example.com", + link_pc="https://example.com", + ), + ], + coupon=BmsCoupon( + title="10% 할인 쿠폰", + description="영화 예매 시 사용 가능한 할인 쿠폰입니다.", + link_mobile="https://example.com/coupon", + ), + ), + ), + ) + + response = message_service.send(full_message) + print("\n전체 필드 메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") +except Exception as e: + print(f"전체 필드 메시지 발송 실패: {str(e)}") diff --git a/examples/simple/send_bms_free_text.py b/examples/simple/send_bms_free_text.py new file mode 100644 index 0000000..4243d03 --- /dev/null +++ b/examples/simple/send_bms_free_text.py @@ -0,0 +1,95 @@ +""" +카카오 BMS 자유형 TEXT 타입 발송 예제 +텍스트 전용 메시지로, 가장 기본적인 형태입니다. +targeting 타입 중 M, N의 경우는 카카오 측에서 인허가된 채널만 사용하실 수 있습니다. +그 외의 모든 채널은 I 타입만 사용 가능합니다. +발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 +""" + +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage +from solapi.model.message_type import MessageType + +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +# 최소 구조 단건 발송 예제 +message = RequestMessage( + from_="발신번호", + to="수신번호", + text="안녕하세요! BMS 자유형 TEXT 메시지입니다.\n\n오늘 하루도 행복하세요!", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="TEXT", + ), + ), +) + +try: + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") +except Exception as e: + print(f"메시지 발송 실패: {str(e)}") + +# 전체 필드 단건 발송 예제 (adult, additionalContent 포함) +full_message = RequestMessage( + from_="발신번호", + to="수신번호", + text="🎉 회원님, 특별한 소식이 있습니다!\n\n이번 주말 단독 할인 이벤트가 진행됩니다.\n자세한 내용은 아래 버튼을 눌러 확인해주세요.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="TEXT", + adult=False, + additional_content="📅 이벤트 기간: 12월 1일 ~ 12월 7일", + ), + ), +) + +try: + response = message_service.send(full_message) + print("\n전체 필드 메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") +except Exception as e: + print(f"전체 필드 메시지 발송 실패: {str(e)}") + +# 다건 발송 예제 +messages = [ + RequestMessage( + from_="발신번호", + to="수신번호1", + text="첫 번째 수신자에게 보내는 BMS 메시지입니다.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms(targeting="I", chat_bubble_type="TEXT"), + ), + ), + RequestMessage( + from_="발신번호", + to="수신번호2", + text="두 번째 수신자에게 보내는 BMS 메시지입니다.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms(targeting="I", chat_bubble_type="TEXT"), + ), + ), +] + +try: + response = message_service.send(messages) + print("\n다건 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"총 메시지 개수: {response.group_info.count.total}") +except Exception as e: + print(f"다건 발송 실패: {str(e)}") diff --git a/examples/simple/send_bms_free_text_with_buttons.py b/examples/simple/send_bms_free_text_with_buttons.py new file mode 100644 index 0000000..2ce6ebc --- /dev/null +++ b/examples/simple/send_bms_free_text_with_buttons.py @@ -0,0 +1,62 @@ +""" +카카오 BMS 자유형 TEXT 타입 + 버튼 발송 예제 +텍스트와 버튼을 포함한 메시지입니다. +BMS 자유형 버튼 타입: WL(웹링크), AL(앱링크), AC(채널추가), BK(봇키워드), MD(상담요청), BC(상담톡전환), BT(챗봇전환), BF(비즈니스폼) +쿠폰 제목 형식: "N원 할인 쿠폰", "N% 할인 쿠폰", "배송비 할인 쿠폰", "OOO 무료 쿠폰", "OOO UP 쿠폰" +발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 +""" + +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage +from solapi.model.kakao.bms import ( + BmsAppButton, + BmsBotKeywordButton, + BmsChannelAddButton, + BmsCoupon, + BmsWebButton, +) +from solapi.model.message_type import MessageType + +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +message = RequestMessage( + from_="발신번호", + to="수신번호", + text="🎁 연말 감사 이벤트!\n\n한 해 동안 함께해주셔서 감사합니다.\n특별한 혜택으로 보답드려요!", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="TEXT", + adult=False, + buttons=[ + BmsWebButton(name="이벤트 참여하기", link_mobile="https://example.com"), + BmsAppButton( + name="앱에서 보기", + link_mobile="https://example.com", + link_android="examplescheme://path", + link_ios="examplescheme://path", + ), + BmsChannelAddButton(name="채널 추가"), + BmsBotKeywordButton(name="이벤트 문의", chat_extra="event_inquiry"), + ], + coupon=BmsCoupon( + title="10000원 할인 쿠폰", + description="연말 감사 할인 쿠폰입니다.", + link_mobile="https://example.com/coupon", + ), + ), + ), +) + +try: + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") +except Exception as e: + print(f"메시지 발송 실패: {str(e)}") diff --git a/examples/simple/send_bms_free_wide.py b/examples/simple/send_bms_free_wide.py new file mode 100644 index 0000000..335058e --- /dev/null +++ b/examples/simple/send_bms_free_wide.py @@ -0,0 +1,54 @@ +""" +카카오 BMS 자유형 WIDE 타입 발송 예제 +와이드 이미지를 사용하는 메시지입니다. +이미지 업로드 시 fileType은 'BMS_WIDE'를 사용해야 합니다. (2:1 비율 이미지 권장) +발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 +""" + +from os.path import abspath + +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage +from solapi.model.kakao.bms import BmsWebButton +from solapi.model.message_type import MessageType +from solapi.model.request.storage import FileTypeEnum + +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +try: + file_response = message_service.upload_file( + file_path=abspath("../images/example_wide.jpg"), + upload_type=FileTypeEnum.BMS_WIDE, + ) + print(f"파일 업로드 성공! File ID: {file_response.file_id}") + + message = RequestMessage( + from_="발신번호", + to="수신번호", + text="✨ 이번 시즌 신상품을 만나보세요!\n\n트렌디한 스타일로 가을을 준비하세요.", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="WIDE", + image_id=file_response.file_id, + buttons=[ + BmsWebButton( + name="자세히 보기", + link_mobile="https://example.com", + ), + ], + ), + ), + ) + + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") +except Exception as e: + print(f"발송 실패: {str(e)}") diff --git a/examples/simple/send_bms_free_wide_item_list.py b/examples/simple/send_bms_free_wide_item_list.py new file mode 100644 index 0000000..458ff42 --- /dev/null +++ b/examples/simple/send_bms_free_wide_item_list.py @@ -0,0 +1,84 @@ +""" +카카오 BMS 자유형 WIDE_ITEM_LIST 타입 발송 예제 +와이드 아이템 리스트 형식으로, 메인 아이템(2:1 비율)과 서브 아이템(1:1 비율)으로 구성됩니다. +메인 아이템: fileType은 'BMS_WIDE_MAIN_ITEM_LIST' (2:1 비율 이미지 필수) +서브 아이템: fileType은 'BMS_WIDE_SUB_ITEM_LIST' (1:1 비율 이미지 필수, 최소 3개 필요) +발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 +""" + +from os.path import abspath + +from solapi import SolapiMessageService +from solapi.model import Bms, KakaoOption, RequestMessage +from solapi.model.kakao.bms import BmsMainWideItem, BmsSubWideItem, BmsWebButton +from solapi.model.message_type import MessageType +from solapi.model.request.storage import FileTypeEnum + +message_service = SolapiMessageService( + api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET" +) + +try: + main_file_response = message_service.upload_file( + file_path=abspath("../images/example_wide.jpg"), + upload_type=FileTypeEnum.BMS_WIDE_MAIN_ITEM_LIST, + ) + main_image_id = main_file_response.file_id + print(f"메인 이미지 업로드 성공! File ID: {main_image_id}") + + sub_file_response = message_service.upload_file( + file_path=abspath("../images/example_square.jpg"), + upload_type=FileTypeEnum.BMS_WIDE_SUB_ITEM_LIST, + ) + sub_image_id = sub_file_response.file_id + print(f"서브 이미지 업로드 성공! File ID: {sub_image_id}") + + message = RequestMessage( + from_="발신번호", + to="수신번호", + type=MessageType.BMS_FREE, + kakao_options=KakaoOption( + pf_id="연동한 비즈니스 채널의 pfId", + bms=Bms( + targeting="I", + chat_bubble_type="WIDE_ITEM_LIST", + header="🏆 베스트 상품 모음", + main_wide_item=BmsMainWideItem( + image_id=main_image_id, + title="이번 주 인기 상품", + link_mobile="https://example.com/main", + ), + sub_wide_item_list=[ + BmsSubWideItem( + image_id=sub_image_id, + title="인기 1위 - 프리미엄 티셔츠", + link_mobile="https://example.com/item1", + ), + BmsSubWideItem( + image_id=sub_image_id, + title="인기 2위 - 캐주얼 팬츠", + link_mobile="https://example.com/item2", + ), + BmsSubWideItem( + image_id=sub_image_id, + title="인기 3위 - 데일리 백", + link_mobile="https://example.com/item3", + ), + ], + buttons=[ + BmsWebButton( + name="전체 상품 보기", + link_mobile="https://example.com", + ), + ], + ), + ), + ) + + response = message_service.send(message) + print("메시지 발송 성공!") + print(f"Group ID: {response.group_info.group_id}") + print(f"요청한 메시지 개수: {response.group_info.count.total}") + print(f"성공한 메시지 개수: {response.group_info.count.registered_success}") +except Exception as e: + print(f"발송 실패: {str(e)}") diff --git a/solapi/model/kakao/bms/__init__.py b/solapi/model/kakao/bms/__init__.py index 915c673..72e6010 100644 --- a/solapi/model/kakao/bms/__init__.py +++ b/solapi/model/kakao/bms/__init__.py @@ -23,7 +23,11 @@ ) from solapi.model.kakao.bms.bms_commerce import BmsCommerce from solapi.model.kakao.bms.bms_coupon import BmsCoupon -from solapi.model.kakao.bms.bms_option import BmsChatBubbleType, BmsOption +from solapi.model.kakao.bms.bms_option import ( + BmsChatBubbleType, + BmsOption, + validate_bms_required_fields, +) from solapi.model.kakao.bms.bms_video import BmsVideo from solapi.model.kakao.bms.bms_wide_item import BmsMainWideItem, BmsSubWideItem @@ -59,4 +63,6 @@ # Option "BmsChatBubbleType", "BmsOption", + # Validation + "validate_bms_required_fields", ] diff --git a/solapi/model/kakao/bms/bms_option.py b/solapi/model/kakao/bms/bms_option.py index ee6f07f..8e2ceff 100644 --- a/solapi/model/kakao/bms/bms_option.py +++ b/solapi/model/kakao/bms/bms_option.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional, Union +from typing import Any, Callable, Literal, Optional, Union from pydantic import BaseModel, ConfigDict, model_validator from pydantic.alias_generators import to_camel @@ -38,6 +38,43 @@ WIDE_ITEM_LIST_MIN_SUB_ITEMS = 3 +def _to_camel(s: str) -> str: + components = s.split("_") + return components[0] + "".join(x.title() for x in components[1:]) + + +def validate_bms_required_fields( + chat_bubble_type: Optional[BmsChatBubbleType], + sub_wide_item_list: Optional[list], + get_field_value: Callable[[str], Any], +) -> None: + if chat_bubble_type is None: + return + + required_fields = BMS_REQUIRED_FIELDS.get(chat_bubble_type, []) + missing_fields = [ + field for field in required_fields if get_field_value(field) is None + ] + + if missing_fields: + camel_fields = [_to_camel(f) for f in missing_fields] + raise ValueError( + f"BMS {chat_bubble_type} 타입에 필수 필드가 누락되었습니다: " + f"{', '.join(camel_fields)}" + ) + + if chat_bubble_type == "WIDE_ITEM_LIST": + if ( + not sub_wide_item_list + or len(sub_wide_item_list) < WIDE_ITEM_LIST_MIN_SUB_ITEMS + ): + raise ValueError( + f"WIDE_ITEM_LIST 타입의 subWideItemList는 최소 " + f"{WIDE_ITEM_LIST_MIN_SUB_ITEMS}개 이상이어야 합니다. " + f"현재: {len(sub_wide_item_list) if sub_wide_item_list else 0}개" + ) + + class BmsOption(BaseModel): targeting: Literal["I", "M", "N"] chat_bubble_type: BmsChatBubbleType @@ -61,34 +98,9 @@ class BmsOption(BaseModel): @model_validator(mode="after") def validate_required_fields(self) -> "BmsOption": - chat_bubble_type = self.chat_bubble_type - required_fields = BMS_REQUIRED_FIELDS.get(chat_bubble_type, []) - missing_fields = [ - field for field in required_fields if getattr(self, field, None) is None - ] - - if missing_fields: - camel_fields = [_to_camel(f) for f in missing_fields] - raise ValueError( - f"BMS {chat_bubble_type} 타입에 필수 필드가 누락되었습니다: " - f"{', '.join(camel_fields)}" - ) - - if chat_bubble_type == "WIDE_ITEM_LIST": - sub_wide_item_list = self.sub_wide_item_list - if ( - not sub_wide_item_list - or len(sub_wide_item_list) < WIDE_ITEM_LIST_MIN_SUB_ITEMS - ): - raise ValueError( - f"WIDE_ITEM_LIST 타입의 subWideItemList는 최소 " - f"{WIDE_ITEM_LIST_MIN_SUB_ITEMS}개 이상이어야 합니다. " - f"현재: {len(sub_wide_item_list) if sub_wide_item_list else 0}개" - ) - + validate_bms_required_fields( + chat_bubble_type=self.chat_bubble_type, + sub_wide_item_list=self.sub_wide_item_list, + get_field_value=lambda field: getattr(self, field, None), + ) return self - - -def _to_camel(s: str) -> str: - components = s.split("_") - return components[0] + "".join(x.title() for x in components[1:]) diff --git a/solapi/model/request/kakao/bms.py b/solapi/model/request/kakao/bms.py index 1bf605b..f17e6b6 100644 --- a/solapi/model/request/kakao/bms.py +++ b/solapi/model/request/kakao/bms.py @@ -11,10 +11,8 @@ from solapi.model.kakao.bms.bms_commerce import BmsCommerce from solapi.model.kakao.bms.bms_coupon import BmsCoupon from solapi.model.kakao.bms.bms_option import ( - BMS_REQUIRED_FIELDS, - WIDE_ITEM_LIST_MIN_SUB_ITEMS, BmsChatBubbleType, - _to_camel, + validate_bms_required_fields, ) from solapi.model.kakao.bms.bms_video import BmsVideo from solapi.model.kakao.bms.bms_wide_item import BmsMainWideItem, BmsSubWideItem @@ -47,32 +45,9 @@ class Bms(BaseModel): @model_validator(mode="after") def validate_required_fields(self) -> "Bms": - chat_bubble_type = self.chat_bubble_type - if chat_bubble_type is None: - return self - - required_fields = BMS_REQUIRED_FIELDS.get(chat_bubble_type, []) - missing_fields = [ - field for field in required_fields if getattr(self, field, None) is None - ] - - if missing_fields: - camel_fields = [_to_camel(f) for f in missing_fields] - raise ValueError( - f"BMS {chat_bubble_type} 타입에 필수 필드가 누락되었습니다: " - f"{', '.join(camel_fields)}" - ) - - if chat_bubble_type == "WIDE_ITEM_LIST": - sub_wide_item_list = self.sub_wide_item_list - if ( - not sub_wide_item_list - or len(sub_wide_item_list) < WIDE_ITEM_LIST_MIN_SUB_ITEMS - ): - raise ValueError( - f"WIDE_ITEM_LIST 타입의 subWideItemList는 최소 " - f"{WIDE_ITEM_LIST_MIN_SUB_ITEMS}개 이상이어야 합니다. " - f"현재: {len(sub_wide_item_list) if sub_wide_item_list else 0}개" - ) - + validate_bms_required_fields( + chat_bubble_type=self.chat_bubble_type, + sub_wide_item_list=self.sub_wide_item_list, + get_field_value=lambda field: getattr(self, field, None), + ) return self diff --git a/uv.lock b/uv.lock index 735b56a..71b8abc 100644 --- a/uv.lock +++ b/uv.lock @@ -399,9 +399,6 @@ requires-dist = [ ] provides-extras = ["dev"] -[package.metadata.requires-dev] -dev = [] - [[package]] name = "sqlparse" version = "0.5.3" From 2a1e79f98c65931ee5ece73bfba7b2406fcff686 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 21:40:37 +0900 Subject: [PATCH 58/60] Add validation for coupon description in BmsCoupon model - Introduced a new field validator for the "description" field in the BmsCoupon class. - Ensures that the description is limited to a maximum of 12 characters, enhancing input validation and error handling. --- solapi/model/kakao/bms/bms_coupon.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/solapi/model/kakao/bms/bms_coupon.py b/solapi/model/kakao/bms/bms_coupon.py index 28c7dca..c412e5f 100644 --- a/solapi/model/kakao/bms/bms_coupon.py +++ b/solapi/model/kakao/bms/bms_coupon.py @@ -53,3 +53,12 @@ def validate_coupon_title(cls, v: Optional[str]) -> Optional[str]: '"OOO UP 쿠폰" (7자 이내)' ) return v + + @field_validator("description") + @classmethod + def validate_coupon_description(cls, v: Optional[str]) -> Optional[str]: + if v is None: + return v + if len(v) > 12: + raise ValueError("쿠폰 설명은 최대 12자 이하로 입력해주세요.") + return v From e90e83ffdf9cfb6c99d3fe9d4b8d3eeeb0d5cfaa Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 21:56:16 +0900 Subject: [PATCH 59/60] Refactor file path handling and update coupon descriptions in BMS examples - Replaced `os.path.abspath` with `pathlib.Path` for improved file path handling in multiple example scripts. - Shortened coupon descriptions in various BMS examples for better clarity and consistency. --- examples/simple/send_bms_free_carousel_commerce.py | 6 +++--- examples/simple/send_bms_free_carousel_feed.py | 6 +++--- examples/simple/send_bms_free_commerce.py | 6 +++--- examples/simple/send_bms_free_image.py | 4 ++-- examples/simple/send_bms_free_image_with_buttons.py | 6 +++--- examples/simple/send_bms_free_premium_video.py | 6 +++--- examples/simple/send_bms_free_wide.py | 4 ++-- examples/simple/send_bms_free_wide_item_list.py | 6 +++--- 8 files changed, 22 insertions(+), 22 deletions(-) diff --git a/examples/simple/send_bms_free_carousel_commerce.py b/examples/simple/send_bms_free_carousel_commerce.py index 68fef1b..57c8d0e 100644 --- a/examples/simple/send_bms_free_carousel_commerce.py +++ b/examples/simple/send_bms_free_carousel_commerce.py @@ -10,7 +10,7 @@ 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 """ -from os.path import abspath +from pathlib import Path from solapi import SolapiMessageService from solapi.model import Bms, KakaoOption, RequestMessage @@ -33,7 +33,7 @@ try: file_response = message_service.upload_file( - file_path=abspath("../images/example_wide.jpg"), + file_path=str(Path(__file__).parent / "../images/example_wide.jpg"), upload_type=FileTypeEnum.BMS_CAROUSEL_COMMERCE_LIST, ) image_id = file_response.file_id @@ -83,7 +83,7 @@ ], coupon=BmsCoupon( title="10000원 할인 쿠폰", - description="첫 구매 고객 전용 쿠폰입니다.", + description="첫 구매 고객 전용", link_mobile="https://example.com/coupon", ), ), diff --git a/examples/simple/send_bms_free_carousel_feed.py b/examples/simple/send_bms_free_carousel_feed.py index 7709126..db2a240 100644 --- a/examples/simple/send_bms_free_carousel_feed.py +++ b/examples/simple/send_bms_free_carousel_feed.py @@ -8,7 +8,7 @@ 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 """ -from os.path import abspath +from pathlib import Path from solapi import SolapiMessageService from solapi.model import Bms, KakaoOption, RequestMessage @@ -29,7 +29,7 @@ try: file_response = message_service.upload_file( - file_path=abspath("../images/example_wide.jpg"), + file_path=str(Path(__file__).parent / "../images/example_wide.jpg"), upload_type=FileTypeEnum.BMS_CAROUSEL_FEED_LIST, ) image_id = file_response.file_id @@ -67,7 +67,7 @@ ], coupon=BmsCoupon( title="10% 할인 쿠폰", - description="첫 등록 고객 전용 할인 쿠폰입니다.", + description="첫 등록 고객 전용", link_mobile="https://example.com/coupon", ), ), diff --git a/examples/simple/send_bms_free_commerce.py b/examples/simple/send_bms_free_commerce.py index 24c1d24..9a9d14d 100644 --- a/examples/simple/send_bms_free_commerce.py +++ b/examples/simple/send_bms_free_commerce.py @@ -8,7 +8,7 @@ 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 """ -from os.path import abspath +from pathlib import Path from solapi import SolapiMessageService from solapi.model import Bms, KakaoOption, RequestMessage @@ -27,7 +27,7 @@ try: file_response = message_service.upload_file( - file_path=abspath("../images/example_wide.jpg"), + file_path=str(Path(__file__).parent / "../images/example_wide.jpg"), upload_type=FileTypeEnum.BMS, ) print(f"파일 업로드 성공! File ID: {file_response.file_id}") @@ -65,7 +65,7 @@ ], coupon=BmsCoupon( title="포인트 UP 쿠폰", - description="구매 시 2배 적립 쿠폰입니다.", + description="구매 시 2배 적립", link_mobile="https://example.com/coupon", ), ), diff --git a/examples/simple/send_bms_free_image.py b/examples/simple/send_bms_free_image.py index 68bc9fd..f2c9e1c 100644 --- a/examples/simple/send_bms_free_image.py +++ b/examples/simple/send_bms_free_image.py @@ -5,7 +5,7 @@ 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 """ -from os.path import abspath +from pathlib import Path from solapi import SolapiMessageService from solapi.model import Bms, KakaoOption, RequestMessage @@ -18,7 +18,7 @@ try: file_response = message_service.upload_file( - file_path=abspath("../images/example.jpg"), + file_path=str(Path(__file__).parent / "../images/example_square.jpg"), upload_type=FileTypeEnum.BMS, ) print(f"파일 업로드 성공! File ID: {file_response.file_id}") diff --git a/examples/simple/send_bms_free_image_with_buttons.py b/examples/simple/send_bms_free_image_with_buttons.py index eb16855..28ba55f 100644 --- a/examples/simple/send_bms_free_image_with_buttons.py +++ b/examples/simple/send_bms_free_image_with_buttons.py @@ -7,7 +7,7 @@ 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 """ -from os.path import abspath +from pathlib import Path from solapi import SolapiMessageService from solapi.model import Bms, KakaoOption, RequestMessage @@ -26,7 +26,7 @@ try: file_response = message_service.upload_file( - file_path=abspath("../images/example.jpg"), + file_path=str(Path(__file__).parent / "../images/example_square.jpg"), upload_type=FileTypeEnum.BMS, ) print(f"파일 업로드 성공! File ID: {file_response.file_id}") @@ -60,7 +60,7 @@ ], coupon=BmsCoupon( title="10000원 할인 쿠폰", - description="연말 감사 할인 쿠폰입니다.", + description="연말 감사 할인", link_mobile="https://example.com/coupon", ), ), diff --git a/examples/simple/send_bms_free_premium_video.py b/examples/simple/send_bms_free_premium_video.py index a2f0009..4406160 100644 --- a/examples/simple/send_bms_free_premium_video.py +++ b/examples/simple/send_bms_free_premium_video.py @@ -7,7 +7,7 @@ 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 """ -from os.path import abspath +from pathlib import Path from solapi import SolapiMessageService from solapi.model import Bms, KakaoOption, RequestMessage @@ -47,7 +47,7 @@ try: file_response = message_service.upload_file( - file_path=abspath("../images/example.jpg"), + file_path=str(Path(__file__).parent / "../images/example_square.jpg"), upload_type=FileTypeEnum.KAKAO, ) @@ -78,7 +78,7 @@ ], coupon=BmsCoupon( title="10% 할인 쿠폰", - description="영화 예매 시 사용 가능한 할인 쿠폰입니다.", + description="영화 예매 시 할인", link_mobile="https://example.com/coupon", ), ), diff --git a/examples/simple/send_bms_free_wide.py b/examples/simple/send_bms_free_wide.py index 335058e..093682e 100644 --- a/examples/simple/send_bms_free_wide.py +++ b/examples/simple/send_bms_free_wide.py @@ -5,7 +5,7 @@ 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 """ -from os.path import abspath +from pathlib import Path from solapi import SolapiMessageService from solapi.model import Bms, KakaoOption, RequestMessage @@ -19,7 +19,7 @@ try: file_response = message_service.upload_file( - file_path=abspath("../images/example_wide.jpg"), + file_path=str(Path(__file__).parent / "../images/example_wide.jpg"), upload_type=FileTypeEnum.BMS_WIDE, ) print(f"파일 업로드 성공! File ID: {file_response.file_id}") diff --git a/examples/simple/send_bms_free_wide_item_list.py b/examples/simple/send_bms_free_wide_item_list.py index 458ff42..2d46d46 100644 --- a/examples/simple/send_bms_free_wide_item_list.py +++ b/examples/simple/send_bms_free_wide_item_list.py @@ -6,7 +6,7 @@ 발신번호, 수신번호에 반드시 -, * 등 특수문자를 제거하여 기입하시기 바랍니다. 예) 01012345678 """ -from os.path import abspath +from pathlib import Path from solapi import SolapiMessageService from solapi.model import Bms, KakaoOption, RequestMessage @@ -20,14 +20,14 @@ try: main_file_response = message_service.upload_file( - file_path=abspath("../images/example_wide.jpg"), + file_path=str(Path(__file__).parent / "../images/example_wide.jpg"), upload_type=FileTypeEnum.BMS_WIDE_MAIN_ITEM_LIST, ) main_image_id = main_file_response.file_id print(f"메인 이미지 업로드 성공! File ID: {main_image_id}") sub_file_response = message_service.upload_file( - file_path=abspath("../images/example_square.jpg"), + file_path=str(Path(__file__).parent / "../images/example_square.jpg"), upload_type=FileTypeEnum.BMS_WIDE_SUB_ITEM_LIST, ) sub_image_id = sub_file_response.file_id From c96af92d5bbc07155aaa9b04be00e69a4af023f5 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 22:00:29 +0900 Subject: [PATCH 60/60] Add omc directory to .gitignore - Added .omc/ to the .gitignore file to prevent tracking of omc-related files in the repository. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 118a46a..8d3bfd2 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ debug # ruff .ruff_cache/ + +# omc +.omc/ \ No newline at end of file