diff --git a/Pipfile.lock b/Pipfile.lock index a303463..8bca5fc 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -18,115 +18,146 @@ "default": { "certifi": { "hashes": [ - "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b", - "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90" + "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", + "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120" ], - "markers": "python_version >= '3.6'", - "version": "==2024.7.4" + "markers": "python_version >= '3.7'", + "version": "==2026.1.4" }, "charset-normalizer": { "hashes": [ - "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", - "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", - "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", - "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", - "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", - "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", - "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", - "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", - "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", - "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", - "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", - "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", - "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", - "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", - "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", - "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", - "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", - "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", - "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", - "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", - "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", - "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", - "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", - "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", - "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", - "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", - "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", - "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", - "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", - "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", - "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", - "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", - "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", - "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", - "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", - "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", - "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", - "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", - "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", - "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", - "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", - "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", - "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", - "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", - "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", - "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", - "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", - "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", - "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", - "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", - "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", - "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", - "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", - "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", - "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", - "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", - "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", - "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", - "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", - "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", - "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", - "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", - "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", - "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", - "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", - "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", - "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", - "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", - "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", - "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", - "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", - "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", - "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", - "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", - "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", - "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", - "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", - "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", - "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", - "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", - "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", - "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", - "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", - "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", - "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", - "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", - "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", - "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", - "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", - "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" + "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", + "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", + "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", + "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", + "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc", + "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", + "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63", + "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d", + "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", + "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", + "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", + "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505", + "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", + "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af", + "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", + "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318", + "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", + "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", + "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", + "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", + "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576", + "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", + "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1", + "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", + "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1", + "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", + "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", + "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", + "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88", + "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", + "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", + "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", + "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a", + "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", + "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", + "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", + "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", + "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", + "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7", + "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", + "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", + "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", + "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", + "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", + "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", + "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2", + "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", + "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", + "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", + "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", + "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf", + "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6", + "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", + "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", + "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa", + "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", + "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", + "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", + "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", + "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", + "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", + "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", + "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", + "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", + "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", + "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", + "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", + "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", + "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", + "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", + "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3", + "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9", + "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", + "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", + "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", + "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", + "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50", + "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf", + "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", + "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", + "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac", + "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", + "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", + "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c", + "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", + "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", + "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e", + "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4", + "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84", + "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", + "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", + "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", + "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", + "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", + "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", + "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", + "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", + "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", + "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074", + "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3", + "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", + "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", + "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", + "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d", + "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", + "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", + "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", + "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", + "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966", + "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", + "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3", + "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", + "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608" + ], + "markers": "python_version >= '3.7'", + "version": "==3.4.4" + }, + "click": { + "hashes": [ + "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", + "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a" ], - "markers": "python_full_version >= '3.7.0'", - "version": "==3.3.2" + "markers": "python_version >= '3.7'", + "version": "==8.1.8" }, "idna": { "hashes": [ - "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc", - "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0" + "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", + "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" ], - "markers": "python_version >= '3.5'", - "version": "==3.7" + "markers": "python_version >= '3.8'", + "version": "==3.11" }, "iso8601": { "hashes": [ @@ -136,6 +167,22 @@ "markers": "python_version >= '3.7' and python_version < '4.0'", "version": "==2.1.0" }, + "markdown-it-py": { + "hashes": [ + "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", + "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb" + ], + "markers": "python_version >= '3.8'", + "version": "==3.0.0" + }, + "mdurl": { + "hashes": [ + "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", + "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" + ], + "markers": "python_version >= '3.7'", + "version": "==0.1.2" + }, "numpy": { "hashes": [ "sha256:04640dab83f7c6c85abf9cd729c5b65f1ebd0ccf9de90b270cd61935eef0197f", @@ -201,6 +248,14 @@ "markers": "python_version >= '3.8'", "version": "==2.0.3" }, + "pygments": { + "hashes": [ + "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", + "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" + ], + "markers": "python_version >= '3.8'", + "version": "==2.19.2" + }, "python-dateutil": { "hashes": [ "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", @@ -211,18 +266,26 @@ }, "pytz": { "hashes": [ - "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812", - "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319" + "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", + "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00" ], - "version": "==2024.1" + "version": "==2025.2" }, "requests": { "hashes": [ - "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", - "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6" + "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", + "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422" ], "markers": "python_version >= '3.8'", - "version": "==2.32.3" + "version": "==2.32.4" + }, + "rich": { + "hashes": [ + "sha256:b8c5f568a3a749f9290ec6bddedf835cec33696bfc1e48bcfecb276c7386e4b8", + "sha256:da750b1aebbff0b372557426fb3f35ba56de8ef954b3190315eb64076d6fb54e" + ], + "markers": "python_full_version >= '3.8.0'", + "version": "==14.3.1" }, "sanpy": { "editable": true, @@ -230,61 +293,85 @@ }, "setuptools": { "hashes": [ - "sha256:032d42ee9fb536e33087fb66cac5f840eb9391ed05637b3f2a76a7c8fb477936", - "sha256:33874fdc59b3188304b2e7c80d9029097ea31627180896fb549c578ceb8a0855" + "sha256:22e8a8ef763eb571d8e414e29b1b9fa9fd0ae3cdcb973ad51c12d8d3ee38ff5c", + "sha256:4f198f37d40550b86ebc2a678e2246ef76fbec316619a5ac61ecbb3cabf8e9bf" ], "markers": "python_version >= '3.8'", - "version": "==71.1.0" + "version": "==75.3.3" + }, + "shellingham": { + "hashes": [ + "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", + "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de" + ], + "markers": "python_version >= '3.7'", + "version": "==1.5.4" }, "six": { "hashes": [ - "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", - "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", + "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.16.0" + "version": "==1.17.0" + }, + "typer": { + "hashes": [ + "sha256:4b3bde918a67c8e03d861aa02deca90a95bbac572e71b1b9be56ff49affdb5a8", + "sha256:68585eb1b01203689c4199bc440d6be616f0851e9f0eb41e4a778845c5a0fd5b" + ], + "markers": "python_version >= '3.8'", + "version": "==0.20.1" + }, + "typing-extensions": { + "hashes": [ + "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", + "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef" + ], + "markers": "python_version >= '3.8'", + "version": "==4.13.2" }, "tzdata": { "hashes": [ - "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd", - "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252" + "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", + "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7" ], "markers": "python_version >= '2'", - "version": "==2024.1" + "version": "==2025.3" }, "urllib3": { "hashes": [ - "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472", - "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168" + "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", + "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9" ], "markers": "python_version >= '3.8'", - "version": "==2.2.2" + "version": "==2.2.3" } }, "develop": { "exceptiongroup": { "hashes": [ - "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", - "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc" + "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", + "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598" ], "markers": "python_version < '3.11'", - "version": "==1.2.2" + "version": "==1.3.1" }, "iniconfig": { "hashes": [ - "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", - "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", + "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760" ], - "markers": "python_version >= '3.7'", - "version": "==2.0.0" + "markers": "python_version >= '3.8'", + "version": "==2.1.0" }, "packaging": { "hashes": [ - "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", - "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124" + "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", + "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529" ], "markers": "python_version >= '3.8'", - "version": "==24.1" + "version": "==26.0" }, "pluggy": { "hashes": [ @@ -296,45 +383,99 @@ }, "pytest": { "hashes": [ - "sha256:7e8e5c5abd6e93cb1cc151f23e57adc31fcf8cfd2a3ff2da63e23f732de35db6", - "sha256:e9600ccf4f563976e2c99fa02c7624ab938296551f280835ee6516df8bc4ae8c" + "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", + "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==8.3.1" + "version": "==8.3.5" }, "ruff": { "hashes": [ - "sha256:029454e2824eafa25b9df46882f7f7844d36fd8ce51c1b7f6d97e2615a57bbcc", - "sha256:09c14ed6a72af9ccc8d2e313d7acf7037f0faff43cde4b507e66f14e812e37f7", - "sha256:0cf497a47751be8c883059c4613ba2f50dd06ec672692de2811f039432875278", - "sha256:2795726d5f71c4f4e70653273d1c23a8182f07dd8e48c12de5d867bfb7557eed", - "sha256:3520a00c0563d7a7a7c324ad7e2cde2355733dafa9592c671fb2e9e3cd8194c1", - "sha256:4c55efbecc3152d614cfe6c2247a3054cfe358cefbf794f8c79c8575456efe19", - "sha256:58b54459221fd3f661a7329f177f091eb35cf7a603f01d9eb3eb11cc348d38c4", - "sha256:628f6b8f97b8bad2490240aa84f3e68f390e13fabc9af5c0d3b96b485921cd60", - "sha256:768fa9208df2bec4b2ce61dbc7c2ddd6b1be9fb48f1f8d3b78b3332c7d71c1ff", - "sha256:82acef724fc639699b4d3177ed5cc14c2a5aacd92edd578a9e846d5b5ec18ddf", - "sha256:93789f14ca2244fb91ed481456f6d0bb8af1f75a330e133b67d08f06ad85b516", - "sha256:9492320eed573a13a0bc09a2957f17aa733fff9ce5bf00e66e6d4a88ec33813f", - "sha256:a6e1f62a92c645e2919b65c02e79d1f61e78a58eddaebca6c23659e7c7cb4ac7", - "sha256:bd53da65f1085fb5b307c38fd3c0829e76acf7b2a912d8d79cadcdb4875c1eb7", - "sha256:da62e87637c8838b325e65beee485f71eb36202ce8e3cdbc24b9fcb8b99a37be", - "sha256:e1e7393e9c56128e870b233c82ceb42164966f25b30f68acbb24ed69ce9c3a4e", - "sha256:e98ad088edfe2f3b85a925ee96da652028f093d6b9b56b76fc242d8abb8e2059", - "sha256:f9b85eaa1f653abd0a70603b8b7008d9e00c9fa1bbd0bf40dad3f0c0bdd06793" + "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", + "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", + "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", + "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", + "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", + "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", + "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", + "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", + "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", + "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", + "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", + "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", + "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", + "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", + "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", + "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", + "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", + "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", + "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==0.5.4" + "version": "==0.14.14" }, "tomli": { "hashes": [ - "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", - "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" + "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", + "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", + "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", + "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", + "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", + "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", + "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", + "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", + "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", + "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", + "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", + "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", + "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", + "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", + "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", + "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", + "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", + "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", + "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", + "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", + "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", + "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", + "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", + "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", + "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", + "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", + "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", + "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", + "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", + "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", + "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", + "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", + "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", + "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", + "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", + "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", + "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", + "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", + "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", + "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", + "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", + "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", + "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", + "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", + "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", + "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", + "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087" ], "markers": "python_version < '3.11'", - "version": "==2.0.1" + "version": "==2.4.0" + }, + "typing-extensions": { + "hashes": [ + "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", + "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef" + ], + "markers": "python_version >= '3.8'", + "version": "==4.13.2" } } } diff --git a/examples/extras/event_study.ipynb b/examples/extras/event_study.ipynb index 74b4447..a57fdd4 100644 --- a/examples/extras/event_study.ipynb +++ b/examples/extras/event_study.ipynb @@ -13,7 +13,6 @@ "metadata": {}, "outputs": [], "source": [ - "from datetime import date\n", "import numpy as np\n", "import san\n", "\n", diff --git a/san/cli.py b/san/cli.py new file mode 100644 index 0000000..0ceaac9 --- /dev/null +++ b/san/cli.py @@ -0,0 +1,358 @@ +""" +Sanpy CLI - Command-line interface for Santiment API. + +Usage: + san --help Show all commands + san get --help Show help for get command + san metrics List all available metrics + san get price_usd --slug bitcoin --format json +""" + +from typing import Optional +import typer +from typing_extensions import Annotated + +import san +from san.cli_config import ( + get_api_key, + set_api_key, + clear_api_key, + get_config_path, + mask_api_key, +) +from san.cli_formatters import ( + format_dataframe, + format_list, + format_dict, + format_api_calls, + output, +) +from san.error import SanError + +# Main app +app = typer.Typer( + name="san", + help="Santiment API CLI - cryptocurrency data at your fingertips.", + add_completion=False, +) + +# Config subcommand group +config_app = typer.Typer(help="Manage API configuration.") +app.add_typer(config_app, name="config") + + +def _init_api_key(api_key: Optional[str] = None) -> None: + """Initialize API key from flag, env var, or config file.""" + if api_key: + san.ApiConfig.api_key = api_key + elif san.ApiConfig.api_key: + # Already set from env var during import + pass + else: + # Try config file + stored_key = get_api_key() + if stored_key: + san.ApiConfig.api_key = stored_key + + +def _handle_error(e: Exception) -> None: + """Handle errors consistently across commands.""" + if isinstance(e, SanError): + output(f"Error: {e}", err=True) + else: + output(f"Unexpected error: {e}", err=True) + raise typer.Exit(code=1) + + +# ============================================================================= +# Config Commands +# ============================================================================= + + +@config_app.command("set-key") +def config_set_key( + api_key: Annotated[str, typer.Argument(help="Your Santiment API key")], +) -> None: + """Store API key in config file.""" + set_api_key(api_key) + output(f"API key saved to {get_config_path()}") + + +@config_app.command("show") +def config_show() -> None: + """Show current configuration.""" + config_path = get_config_path() + stored_key = get_api_key() + env_key = san.ApiConfig.api_key + + output(f"Config file: {config_path}") + output(f"Stored API key: {mask_api_key(stored_key)}") + if env_key and env_key != stored_key: + output(f"Env API key (SANPY_APIKEY): {mask_api_key(env_key)}") + + +@config_app.command("path") +def config_path() -> None: + """Show config file path.""" + output(str(get_config_path())) + + +@config_app.command("clear") +def config_clear() -> None: + """Remove stored API key.""" + clear_api_key() + output("API key cleared from config file.") + + +# ============================================================================= +# Discovery Commands +# ============================================================================= + + +@app.command() +def metrics( + slug: Annotated[ + Optional[str], + typer.Option(help="Filter metrics available for this asset slug"), + ] = None, + format: Annotated[ + str, + typer.Option("--format", "-f", help="Output format: json, csv, table"), + ] = "table", + api_key: Annotated[ + Optional[str], + typer.Option(envvar="SANPY_APIKEY", help="API key"), + ] = None, +) -> None: + """List available metrics.""" + _init_api_key(api_key) + try: + if slug: + result = san.available_metrics_for_slug(slug) + else: + result = san.available_metrics() + output(format_list(result, format)) + except Exception as e: + _handle_error(e) + + +@app.command() +def projects( + format: Annotated[ + str, + typer.Option("--format", "-f", help="Output format: json, csv, table"), + ] = "table", + api_key: Annotated[ + Optional[str], + typer.Option(envvar="SANPY_APIKEY", help="API key"), + ] = None, +) -> None: + """List all available projects/assets.""" + _init_api_key(api_key) + try: + df = san.get("projects/all") + output(format_dataframe(df, format)) + except Exception as e: + _handle_error(e) + + +# ============================================================================= +# Data Fetching Commands +# ============================================================================= + + +@app.command() +def get( + metric: Annotated[str, typer.Argument(help="Metric name (e.g., price_usd, daily_active_addresses)")], + slug: Annotated[str, typer.Option(help="Asset slug (e.g., bitcoin, ethereum)")], + from_date: Annotated[ + str, + typer.Option("--from", help="Start date (ISO format or utc_now-Nd)"), + ] = "utc_now-30d", + to_date: Annotated[ + str, + typer.Option("--to", help="End date (ISO format or utc_now)"), + ] = "utc_now", + interval: Annotated[ + str, + typer.Option(help="Data interval (e.g., 1d, 1h, 1w)"), + ] = "1d", + format: Annotated[ + str, + typer.Option("--format", "-f", help="Output format: json, csv, table"), + ] = "table", + api_key: Annotated[ + Optional[str], + typer.Option(envvar="SANPY_APIKEY", help="API key"), + ] = None, +) -> None: + """Fetch timeseries data for a single metric/asset pair.""" + _init_api_key(api_key) + try: + df = san.get( + metric, + slug=slug, + from_date=from_date, + to_date=to_date, + interval=interval, + ) + output(format_dataframe(df, format)) + except Exception as e: + _handle_error(e) + + +@app.command("get-many") +def get_many( + metric: Annotated[str, typer.Argument(help="Metric name (e.g., price_usd)")], + slugs: Annotated[str, typer.Option(help="Comma-separated asset slugs (e.g., bitcoin,ethereum,solana)")], + from_date: Annotated[ + str, + typer.Option("--from", help="Start date (ISO format or utc_now-Nd)"), + ] = "utc_now-30d", + to_date: Annotated[ + str, + typer.Option("--to", help="End date (ISO format or utc_now)"), + ] = "utc_now", + interval: Annotated[ + str, + typer.Option(help="Data interval (e.g., 1d, 1h, 1w)"), + ] = "1d", + format: Annotated[ + str, + typer.Option("--format", "-f", help="Output format: json, csv, table"), + ] = "table", + api_key: Annotated[ + Optional[str], + typer.Option(envvar="SANPY_APIKEY", help="API key"), + ] = None, +) -> None: + """Fetch timeseries data for a metric across multiple assets.""" + _init_api_key(api_key) + try: + slug_list = [s.strip() for s in slugs.split(",") if s.strip()] + df = san.get_many( + metric, + slugs=slug_list, + from_date=from_date, + to_date=to_date, + interval=interval, + ) + output(format_dataframe(df, format)) + except Exception as e: + _handle_error(e) + + +# ============================================================================= +# Rate Limit & Complexity Commands +# ============================================================================= + + +@app.command("rate-limit") +def rate_limit( + format: Annotated[ + str, + typer.Option("--format", "-f", help="Output format: json, csv, table"), + ] = "table", + api_key: Annotated[ + Optional[str], + typer.Option(envvar="SANPY_APIKEY", help="API key"), + ] = None, +) -> None: + """Show API rate limit status (calls remaining).""" + _init_api_key(api_key) + try: + remaining = san.api_calls_remaining() + output(format_dict(remaining, format)) + except Exception as e: + _handle_error(e) + + +@app.command("api-calls") +def api_calls( + format: Annotated[ + str, + typer.Option("--format", "-f", help="Output format: json, csv, table"), + ] = "table", + api_key: Annotated[ + Optional[str], + typer.Option(envvar="SANPY_APIKEY", help="API key"), + ] = None, +) -> None: + """Show API calls history.""" + _init_api_key(api_key) + try: + calls = san.api_calls_made() + output(format_api_calls(calls, format)) + except Exception as e: + _handle_error(e) + + +@app.command() +def complexity( + metric: Annotated[str, typer.Argument(help="Metric name (e.g., price_usd)")], + from_date: Annotated[ + str, + typer.Option("--from", help="Start date (ISO format or utc_now-Nd)"), + ] = "utc_now-30d", + to_date: Annotated[ + str, + typer.Option("--to", help="End date (ISO format or utc_now)"), + ] = "utc_now", + interval: Annotated[ + str, + typer.Option(help="Data interval (e.g., 1d, 1h, 1w)"), + ] = "1d", + format: Annotated[ + str, + typer.Option("--format", "-f", help="Output format: json, csv, table"), + ] = "table", + api_key: Annotated[ + Optional[str], + typer.Option(envvar="SANPY_APIKEY", help="API key"), + ] = None, +) -> None: + """Check query complexity for a metric.""" + _init_api_key(api_key) + try: + result = san.metric_complexity( + metric, + from_date=from_date, + to_date=to_date, + interval=interval, + ) + data = { + "metric": metric, + "from": from_date, + "to": to_date, + "interval": interval, + "complexity": result, + } + output(format_dict(data, format)) + except Exception as e: + _handle_error(e) + + +# ============================================================================= +# Version callback +# ============================================================================= + + +def version_callback(value: bool) -> None: + if value: + output(f"sanpy {san.__version__}") + raise typer.Exit() + + +@app.callback() +def main( + version: Annotated[ + Optional[bool], + typer.Option("--version", "-v", callback=version_callback, is_eager=True, help="Show version"), + ] = None, +) -> None: + """Santiment API CLI - cryptocurrency data at your fingertips.""" + pass + + +if __name__ == "__main__": + app() diff --git a/san/cli_config.py b/san/cli_config.py new file mode 100644 index 0000000..158f24e --- /dev/null +++ b/san/cli_config.py @@ -0,0 +1,123 @@ +""" +Cross-platform configuration management for sanpy CLI. + +Stores API key and other settings in a platform-appropriate location: +- Linux/Mac: ~/.config/sanpy/config.json +- Windows: %APPDATA%/sanpy/config.json +""" + +import json +import os +from pathlib import Path +from typing import Optional + + +class ConfigError(Exception): + """Exception raised when configuration operations fail. + + This exception provides user-friendly error messages for configuration + issues such as permission errors, disk full, or invalid configuration data. + """ + pass + + +def get_config_dir() -> Path: + """Get the platform-appropriate config directory.""" + if os.name == "nt": # Windows + base = os.environ.get("APPDATA", Path.home()) + else: # Linux/Mac + base = os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config") + + config_dir = Path(base) / "sanpy" + return config_dir + + +def get_config_path() -> Path: + """Get the path to the config file.""" + return get_config_dir() / "config.json" + + +def _ensure_config_dir() -> None: + """Create config directory if it doesn't exist. + + Raises: + ConfigError: If the directory cannot be created due to permission + issues or other filesystem errors. + """ + config_dir = get_config_dir() + try: + config_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + raise ConfigError( + f"Cannot create configuration directory '{config_dir}': {e.strerror}" + ) from e + + +def _load_config() -> dict: + """Load config from file, return empty dict if doesn't exist.""" + config_path = get_config_path() + if config_path.exists(): + try: + with open(config_path, "r") as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return {} + return {} + + +def _save_config(config: dict) -> None: + """Save config to file. + + Raises: + ConfigError: If the configuration cannot be saved due to permission + issues, disk full, or JSON encoding errors. + """ + _ensure_config_dir() + config_path = get_config_path() + try: + with open(config_path, "w") as f: + json.dump(config, f, indent=2) + except (OSError, IOError) as e: + raise ConfigError( + f"Cannot write configuration file '{config_path}': {e.strerror}" + ) from e + except (TypeError, ValueError) as e: + # TypeError/ValueError are raised by json.dump for non-serializable data + raise ConfigError( + f"Cannot encode configuration as JSON: {e}" + ) from e + + +def get_api_key() -> Optional[str]: + """ + Get API key from config file. + + Note: Environment variable SANPY_APIKEY takes precedence, + but that's handled in cli.py at the app level. + """ + config = _load_config() + return config.get("api_key") + + +def set_api_key(api_key: str) -> None: + """Store API key in config file.""" + config = _load_config() + config["api_key"] = api_key + _save_config(config) + + +def clear_api_key() -> None: + """Remove API key from config file.""" + config = _load_config() + if "api_key" in config: + del config["api_key"] + _save_config(config) + + +def mask_api_key(api_key: Optional[str]) -> str: + """Mask API key for display, showing only first/last 4 chars.""" + if not api_key: + return "(not set)" + if len(api_key) <= 8: + return "*" * len(api_key) + return f"{api_key[:4]}...{api_key[-4:]}" diff --git a/san/cli_formatters.py b/san/cli_formatters.py new file mode 100644 index 0000000..6a5168c --- /dev/null +++ b/san/cli_formatters.py @@ -0,0 +1,125 @@ +""" +Output formatters for sanpy CLI. + +Supports JSON, CSV, and table (human-readable) output formats. +""" + +import csv +import io +import json +import sys +from typing import List + +import pandas as pd + + +def format_dataframe(df: pd.DataFrame, fmt: str = "table") -> str: + """ + Format a pandas DataFrame for CLI output. + + Args: + df: The DataFrame to format + fmt: Output format - 'json', 'csv', or 'table' + + Returns: + Formatted string representation + """ + if fmt == "json": + # Reset index to include datetime in output + if df.index.name == "datetime" or isinstance(df.index, pd.DatetimeIndex): + df = df.reset_index() + return df.to_json(orient="records", date_format="iso", indent=2) + elif fmt == "csv": + return df.to_csv() + else: # table (default) + return df.to_string() + + +def format_list(items: List[str], fmt: str = "table") -> str: + """ + Format a list of strings for CLI output. + + Args: + items: List of strings to format + fmt: Output format - 'json', 'csv', or 'table' + + Returns: + Formatted string representation + """ + if fmt == "json": + return json.dumps(items, indent=2) + elif fmt == "csv": + return "\n".join(items) + else: # table + return "\n".join(items) + + +def format_dict(data: dict, fmt: str = "table") -> str: + """ + Format a dictionary for CLI output. + + Args: + data: Dictionary to format + fmt: Output format - 'json', 'csv', or 'table' + + Returns: + Formatted string representation + """ + if fmt == "json": + return json.dumps(data, indent=2, default=str) + elif fmt == "csv": + # Use csv module for proper escaping of commas, quotes, and newlines + buffer = io.StringIO() + writer = csv.writer(buffer, quoting=csv.QUOTE_MINIMAL) + writer.writerow(["key", "value"]) + for k, v in data.items(): + writer.writerow([k, v]) + return buffer.getvalue() + else: # table + max_key_len = max(len(str(k)) for k in data.keys()) if data else 0 + lines = [] + for k, v in data.items(): + lines.append(f"{str(k).ljust(max_key_len)} {v}") + return "\n".join(lines) + + +def format_api_calls(calls: List[tuple], fmt: str = "table") -> str: + """ + Format API calls history for CLI output. + + Args: + calls: List of (datetime, count) tuples + fmt: Output format - 'json', 'csv', or 'table' + + Returns: + Formatted string representation + """ + if fmt == "json": + data = [{"date": str(date), "api_calls": count} for date, count in calls] + return json.dumps(data, indent=2) + elif fmt == "csv": + lines = ["date,api_calls"] + for date, count in calls: + lines.append(f"{date},{count}") + return "\n".join(lines) + else: # table + if not calls: + return "No API calls recorded" + lines = ["Date API Calls", "-" * 35] + for date, count in calls: + lines.append(f"{str(date).ljust(20)} {count}") + return "\n".join(lines) + + +def output(text: str, err: bool = False) -> None: + """ + Print text to stdout or stderr. + + Args: + text: Text to print + err: If True, print to stderr + """ + if err: + print(text, file=sys.stderr) + else: + print(text) diff --git a/san/tests/test_cli.py b/san/tests/test_cli.py new file mode 100644 index 0000000..0c79ed8 --- /dev/null +++ b/san/tests/test_cli.py @@ -0,0 +1,396 @@ +""" +Unit tests for sanpy CLI. + +These tests mock API responses and test CLI behavior. +""" + +import json +import pytest +from unittest.mock import patch +from typer.testing import CliRunner +import pandas as pd + +from san.cli import app + + +runner = CliRunner() + + +# ============================================================================= +# Helper fixtures +# ============================================================================= + + +@pytest.fixture +def mock_api_key(): + """Set up a mock API key for tests.""" + with patch("san.ApiConfig") as mock_config: + mock_config.api_key = "test_api_key_12345" + yield mock_config + + +@pytest.fixture +def sample_dataframe(): + """Create a sample DataFrame for mocking san.get() results.""" + return pd.DataFrame( + { + "value": [100.0, 101.0, 102.0], + }, + index=pd.to_datetime(["2024-01-01", "2024-01-02", "2024-01-03"], utc=True), + ) + + +@pytest.fixture +def sample_projects_dataframe(): + """Create a sample DataFrame for mocking projects results.""" + return pd.DataFrame( + { + "name": ["Bitcoin", "Ethereum", "Santiment"], + "slug": ["bitcoin", "ethereum", "santiment"], + "ticker": ["BTC", "ETH", "SAN"], + } + ) + + +# ============================================================================= +# Version and Help Tests +# ============================================================================= + + +def test_version(): + """Test --version flag.""" + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert "sanpy" in result.stdout + + +def test_help(): + """Test --help flag.""" + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Santiment API CLI" in result.stdout + + +def test_get_help(): + """Test get --help.""" + result = runner.invoke(app, ["get", "--help"]) + assert result.exit_code == 0 + assert "metric" in result.stdout.lower() + assert "slug" in result.stdout.lower() + + +# ============================================================================= +# Config Commands Tests +# ============================================================================= + + +def test_config_path(): + """Test config path command.""" + result = runner.invoke(app, ["config", "path"]) + assert result.exit_code == 0 + assert "sanpy" in result.stdout + assert "config.json" in result.stdout + + +def test_config_set_key(tmp_path, monkeypatch): + """Test setting API key.""" + # Use temp directory for config + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + + result = runner.invoke(app, ["config", "set-key", "my_test_key_123"]) + assert result.exit_code == 0 + assert "saved" in result.stdout.lower() + + +def test_config_show(tmp_path, monkeypatch): + """Test showing config.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + + # First set a key + runner.invoke(app, ["config", "set-key", "test_key_abcd1234"]) + + # Then show config + result = runner.invoke(app, ["config", "show"]) + assert result.exit_code == 0 + assert "test" in result.stdout # Masked key shows first chars + + +def test_config_clear(tmp_path, monkeypatch): + """Test clearing API key.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + + # First set a key + runner.invoke(app, ["config", "set-key", "test_key"]) + + # Then clear it + result = runner.invoke(app, ["config", "clear"]) + assert result.exit_code == 0 + assert "cleared" in result.stdout.lower() + + +# ============================================================================= +# Discovery Commands Tests +# ============================================================================= + + +@patch("san.available_metrics") +def test_metrics(mock_available_metrics): + """Test metrics command.""" + mock_available_metrics.return_value = ["price_usd", "daily_active_addresses", "nvt"] + + result = runner.invoke(app, ["metrics"]) + assert result.exit_code == 0 + assert "price_usd" in result.stdout + assert "daily_active_addresses" in result.stdout + + +@patch("san.available_metrics") +def test_metrics_json_format(mock_available_metrics): + """Test metrics command with JSON output.""" + mock_available_metrics.return_value = ["price_usd", "nvt"] + + result = runner.invoke(app, ["metrics", "--format", "json"]) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert "price_usd" in data + assert "nvt" in data + + +@patch("san.available_metrics_for_slug") +def test_metrics_for_slug(mock_metrics_for_slug): + """Test metrics command with slug filter.""" + mock_metrics_for_slug.return_value = ["price_usd", "daily_active_addresses"] + + result = runner.invoke(app, ["metrics", "--slug", "bitcoin"]) + assert result.exit_code == 0 + mock_metrics_for_slug.assert_called_once_with("bitcoin") + + +@patch("san.get") +def test_projects(mock_get, sample_projects_dataframe): + """Test projects command.""" + mock_get.return_value = sample_projects_dataframe + + result = runner.invoke(app, ["projects"]) + assert result.exit_code == 0 + assert "bitcoin" in result.stdout.lower() + mock_get.assert_called_once_with("projects/all") + + +@patch("san.get") +def test_projects_json_format(mock_get, sample_projects_dataframe): + """Test projects command with JSON output.""" + mock_get.return_value = sample_projects_dataframe + + result = runner.invoke(app, ["projects", "--format", "json"]) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert len(data) == 3 + assert data[0]["slug"] == "bitcoin" + + +# ============================================================================= +# Get Command Tests +# ============================================================================= + + +@patch("san.get") +def test_get_basic(mock_get, sample_dataframe): + """Test basic get command.""" + mock_get.return_value = sample_dataframe + + result = runner.invoke(app, ["get", "price_usd", "--slug", "bitcoin"]) + assert result.exit_code == 0 + mock_get.assert_called_once() + call_kwargs = mock_get.call_args[1] + assert call_kwargs["slug"] == "bitcoin" + + +@patch("san.get") +def test_get_with_dates(mock_get, sample_dataframe): + """Test get command with custom dates.""" + mock_get.return_value = sample_dataframe + + result = runner.invoke( + app, + [ + "get", + "price_usd", + "--slug", + "bitcoin", + "--from", + "2024-01-01", + "--to", + "2024-01-10", + "--interval", + "1d", + ], + ) + assert result.exit_code == 0 + call_kwargs = mock_get.call_args[1] + assert call_kwargs["from_date"] == "2024-01-01" + assert call_kwargs["to_date"] == "2024-01-10" + assert call_kwargs["interval"] == "1d" + + +@patch("san.get") +def test_get_json_format(mock_get, sample_dataframe): + """Test get command with JSON output.""" + mock_get.return_value = sample_dataframe + + result = runner.invoke(app, ["get", "price_usd", "--slug", "bitcoin", "-f", "json"]) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert len(data) == 3 + assert "value" in data[0] + + +@patch("san.get") +def test_get_csv_format(mock_get, sample_dataframe): + """Test get command with CSV output.""" + mock_get.return_value = sample_dataframe + + result = runner.invoke(app, ["get", "price_usd", "--slug", "bitcoin", "-f", "csv"]) + assert result.exit_code == 0 + assert "value" in result.stdout + assert "100.0" in result.stdout + + +@patch("san.get") +def test_get_missing_slug(mock_get): + """Test get command without required slug.""" + result = runner.invoke(app, ["get", "price_usd"]) + assert result.exit_code != 0 # Should fail + + +# ============================================================================= +# Get-Many Command Tests +# ============================================================================= + + +@patch("san.get_many") +def test_get_many_basic(mock_get_many): + """Test basic get-many command.""" + mock_df = pd.DataFrame( + { + "bitcoin": [100.0, 101.0], + "ethereum": [50.0, 51.0], + }, + index=pd.to_datetime(["2024-01-01", "2024-01-02"], utc=True), + ) + mock_get_many.return_value = mock_df + + result = runner.invoke(app, ["get-many", "price_usd", "--slugs", "bitcoin,ethereum"]) + assert result.exit_code == 0 + call_kwargs = mock_get_many.call_args[1] + assert call_kwargs["slugs"] == ["bitcoin", "ethereum"] + + +@patch("san.get_many") +def test_get_many_json_format(mock_get_many): + """Test get-many command with JSON output.""" + mock_df = pd.DataFrame( + { + "bitcoin": [100.0], + "ethereum": [50.0], + }, + index=pd.to_datetime(["2024-01-01"], utc=True), + ) + mock_get_many.return_value = mock_df + + result = runner.invoke(app, ["get-many", "price_usd", "--slugs", "bitcoin,ethereum", "-f", "json"]) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert len(data) == 1 + + +# ============================================================================= +# Rate Limit Commands Tests +# ============================================================================= + + +@patch("san.api_calls_remaining") +def test_rate_limit(mock_remaining): + """Test rate-limit command.""" + mock_remaining.return_value = { + "month_remaining": "9000", + "hour_remaining": "100", + "minute_remaining": "10", + } + + result = runner.invoke(app, ["rate-limit"]) + assert result.exit_code == 0 + assert "9000" in result.stdout + + +@patch("san.api_calls_remaining") +def test_rate_limit_json(mock_remaining): + """Test rate-limit command with JSON output.""" + mock_remaining.return_value = { + "month_remaining": "9000", + "hour_remaining": "100", + "minute_remaining": "10", + } + + result = runner.invoke(app, ["rate-limit", "-f", "json"]) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert data["month_remaining"] == "9000" + + +@patch("san.api_calls_made") +def test_api_calls(mock_calls_made): + """Test api-calls command.""" + mock_calls_made.return_value = [ + ("2024-01-01", 50), + ("2024-01-02", 75), + ] + + result = runner.invoke(app, ["api-calls"]) + assert result.exit_code == 0 + assert "50" in result.stdout + assert "75" in result.stdout + + +# ============================================================================= +# Complexity Command Tests +# ============================================================================= + + +@patch("san.metric_complexity") +def test_complexity(mock_complexity): + """Test complexity command.""" + mock_complexity.return_value = 1500 + + result = runner.invoke(app, ["complexity", "price_usd"]) + assert result.exit_code == 0 + assert "1500" in result.stdout + + +@patch("san.metric_complexity") +def test_complexity_json(mock_complexity): + """Test complexity command with JSON output.""" + mock_complexity.return_value = 1500 + + result = runner.invoke(app, ["complexity", "price_usd", "-f", "json"]) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert data["complexity"] == 1500 + assert data["metric"] == "price_usd" + + +# ============================================================================= +# Error Handling Tests +# ============================================================================= + + +@patch("san.get") +def test_error_handling(mock_get): + """Test that errors are handled gracefully.""" + from san.error import SanError + + mock_get.side_effect = SanError("API Error: Invalid metric") + + result = runner.invoke(app, ["get", "invalid_metric", "--slug", "bitcoin"]) + assert result.exit_code == 1 + assert "error" in result.stdout.lower() diff --git a/san/tests/test_cli_integration.py b/san/tests/test_cli_integration.py new file mode 100644 index 0000000..b89cba2 --- /dev/null +++ b/san/tests/test_cli_integration.py @@ -0,0 +1,338 @@ +""" +Integration tests for sanpy CLI. + +These tests call the real Santiment API and verify CLI behavior. +Run with: pytest -m integration +""" + +import json +import re + +import pytest +from typer.testing import CliRunner + +from san.cli import app + + +runner = CliRunner() + + +# ============================================================================= +# Discovery Commands Integration Tests +# ============================================================================= + + +@pytest.mark.integration +def test_metrics_integration(): + """Test that metrics command returns real metrics.""" + result = runner.invoke(app, ["metrics"]) + assert result.exit_code == 0 + # These metrics should always exist + assert "price_usd" in result.stdout + + +@pytest.mark.integration +def test_metrics_for_slug_integration(): + """Test metrics command with slug filter.""" + result = runner.invoke(app, ["metrics", "--slug", "bitcoin"]) + assert result.exit_code == 0 + assert "price_usd" in result.stdout + + +@pytest.mark.integration +def test_metrics_json_integration(): + """Test metrics command with JSON output.""" + result = runner.invoke(app, ["metrics", "--format", "json"]) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert isinstance(data, list) + assert len(data) > 0 + assert "price_usd" in data + + +@pytest.mark.integration +def test_projects_integration(): + """Test that projects command returns real projects.""" + result = runner.invoke(app, ["projects"]) + assert result.exit_code == 0 + assert "bitcoin" in result.stdout.lower() + + +@pytest.mark.integration +def test_projects_json_integration(): + """Test projects command with JSON output.""" + result = runner.invoke(app, ["projects", "--format", "json"]) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert isinstance(data, list) + assert len(data) > 0 + # Check structure + slugs = [p["slug"] for p in data] + assert "bitcoin" in slugs + + +# ============================================================================= +# Get Command Integration Tests +# ============================================================================= + + +@pytest.mark.integration +def test_get_price_integration(): + """Test fetching price data.""" + result = runner.invoke( + app, + [ + "get", + "price_usd", + "--slug", + "bitcoin", + "--from", + "utc_now-7d", + "--to", + "utc_now-2d", + "--interval", + "1d", + ], + ) + assert result.exit_code == 0 + # Verify column header is present + assert "value" in result.stdout, "Expected 'value' column header in output" + # Verify actual numeric data rows exist (decimal values, not just dates) + # Using regex to match standalone decimal numbers (e.g., "12345.67") + # This avoids false positives from dates like "2024-01-20" + assert re.search(r"\b\d+\.\d+\b", result.stdout), "Expected numeric data rows with decimal values" + + +@pytest.mark.integration +def test_get_json_integration(): + """Test fetching data with JSON output.""" + result = runner.invoke( + app, + [ + "get", + "price_usd", + "--slug", + "bitcoin", + "--from", + "utc_now-7d", + "--to", + "utc_now-2d", + "--interval", + "1d", + "-f", + "json", + ], + ) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert isinstance(data, list) + assert len(data) > 0 + # Check structure - should have datetime and value + assert "datetime" in data[0] or "value" in data[0] + + +@pytest.mark.integration +def test_get_csv_integration(): + """Test fetching data with CSV output.""" + result = runner.invoke( + app, + [ + "get", + "price_usd", + "--slug", + "bitcoin", + "--from", + "utc_now-7d", + "--to", + "utc_now-2d", + "-f", + "csv", + ], + ) + assert result.exit_code == 0 + lines = result.stdout.strip().split("\n") + assert len(lines) > 1 # Header + data + + +@pytest.mark.integration +def test_get_daily_active_addresses_integration(): + """Test fetching DAA metric.""" + result = runner.invoke( + app, + [ + "get", + "daily_active_addresses", + "--slug", + "bitcoin", + "--from", + "utc_now-7d", + "--to", + "utc_now-2d", + "--interval", + "1d", + ], + ) + assert result.exit_code == 0 + + +# ============================================================================= +# Get-Many Command Integration Tests +# ============================================================================= + + +@pytest.mark.integration +def test_get_many_integration(): + """Test fetching data for multiple assets.""" + result = runner.invoke( + app, + [ + "get-many", + "price_usd", + "--slugs", + "bitcoin,ethereum", + "--from", + "utc_now-7d", + "--to", + "utc_now-2d", + "--interval", + "1d", + ], + ) + assert result.exit_code == 0 + # Should have columns for both assets + assert "bitcoin" in result.stdout.lower() or "ethereum" in result.stdout.lower() + + +@pytest.mark.integration +def test_get_many_json_integration(): + """Test get-many with JSON output.""" + result = runner.invoke( + app, + [ + "get-many", + "price_usd", + "--slugs", + "bitcoin,ethereum", + "--from", + "utc_now-7d", + "--to", + "utc_now-2d", + "-f", + "json", + ], + ) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert isinstance(data, list) + + +# ============================================================================= +# Complexity Command Integration Tests +# ============================================================================= + + +@pytest.mark.integration +def test_complexity_integration(): + """Test complexity calculation.""" + result = runner.invoke( + app, + [ + "complexity", + "price_usd", + "--from", + "utc_now-30d", + "--to", + "utc_now", + "--interval", + "1d", + ], + ) + assert result.exit_code == 0 + assert "complexity" in result.stdout.lower() + + +@pytest.mark.integration +def test_complexity_json_integration(): + """Test complexity with JSON output.""" + result = runner.invoke( + app, + [ + "complexity", + "price_usd", + "--from", + "utc_now-30d", + "--to", + "utc_now", + "-f", + "json", + ], + ) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert "complexity" in data + assert "metric" in data + assert data["metric"] == "price_usd" + assert isinstance(data["complexity"], int) + + +# ============================================================================= +# Rate Limit Commands Integration Tests (require API key) +# ============================================================================= + + +@pytest.mark.integration +def test_rate_limit_integration(): + """Test rate limit command (requires API key).""" + result = runner.invoke(app, ["rate-limit"]) + # May fail without API key, but should handle gracefully + if result.exit_code == 0: + assert "remaining" in result.stdout.lower() or "month" in result.stdout.lower() + else: + # Expected to fail without API key + assert "error" in result.stdout.lower() or result.exit_code == 1 + + +# ============================================================================= +# Error Handling Integration Tests +# ============================================================================= + + +@pytest.mark.integration +def test_invalid_metric_integration(): + """Test handling of invalid metric name.""" + result = runner.invoke( + app, + [ + "get", + "completely_invalid_metric_xyz123", + "--slug", + "bitcoin", + "--from", + "utc_now-7d", + "--to", + "utc_now-2d", + ], + ) + # Should fail gracefully + assert result.exit_code == 1 + assert "error" in result.stdout.lower() + + +@pytest.mark.integration +def test_invalid_slug_integration(): + """Test handling of invalid slug.""" + result = runner.invoke( + app, + [ + "get", + "price_usd", + "--slug", + "completely_invalid_slug_xyz123", + "--from", + "utc_now-7d", + "--to", + "utc_now-2d", + ], + ) + # Should fail gracefully + assert result.exit_code == 1 diff --git a/setup.py b/setup.py index 1122476..076e053 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setuptools.setup( name="sanpy", - version="0.12.4", + version="0.13.0", author="Santiment", author_email="admin@santiment.net", description="Package for Santiment API access with python", @@ -16,9 +16,14 @@ url="https://github.com/santiment/sanpy", packages=setuptools.find_packages(), setup_requires=["numpy", "Cython"], - install_requires=["pandas>=1.3.0", "requests", "iso8601", "setuptools"], + install_requires=["pandas>=1.3.0", "requests", "iso8601", "setuptools", "typer>=0.9.0"], extras_require={ "extras": ["numpy", "matplotlib", "scipy", "mlfinlab"], "dev": ["ruff", "pytest"], }, + entry_points={ + "console_scripts": [ + "san=san.cli:app", + ], + }, )