├── .gitignore ├── .pytest_cache └── v │ └── cache │ └── nodeids ├── .travis.yml ├── LICENSE ├── README.rst ├── examples ├── list.py └── post.py ├── requirements-dev.txt ├── setup.py ├── slacker ├── __init__.py └── utils.py ├── tests ├── __init__.py ├── test_channels.py └── test_utils.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Mac OS 2 | .DS_Store 3 | 4 | # Python 5 | *.pyc 6 | *.pyo 7 | dist 8 | slacker.egg-info 9 | .tox 10 | ### JetBrains template 11 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 12 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 13 | 14 | # User-specific stuff: 15 | .idea/**/workspace.xml 16 | .idea/**/tasks.xml 17 | .idea/dictionaries 18 | 19 | # Sensitive or high-churn files: 20 | .idea/**/dataSources/ 21 | .idea/**/dataSources.ids 22 | .idea/**/dataSources.xml 23 | .idea/**/dataSources.local.xml 24 | .idea/**/sqlDataSources.xml 25 | .idea/**/dynamic.xml 26 | .idea/**/uiDesigner.xml 27 | 28 | # Gradle: 29 | .idea/**/gradle.xml 30 | .idea/**/libraries 31 | 32 | # Mongo Explorer plugin: 33 | .idea/**/mongoSettings.xml 34 | 35 | ## File-based project format: 36 | *.iws 37 | 38 | ## Plugin-specific files: 39 | 40 | # IntelliJ 41 | /out/ 42 | 43 | # mpeltonen/sbt-idea plugin 44 | .idea_modules/ 45 | 46 | # JIRA plugin 47 | atlassian-ide-plugin.xml 48 | 49 | # Crashlytics plugin (for Android Studio and IntelliJ) 50 | com_crashlytics_export_strings.xml 51 | crashlytics.properties 52 | crashlytics-build.properties 53 | fabric.properties 54 | ### Linux template 55 | *~ 56 | 57 | # temporary files which can be created if a process still has a handle open of a deleted file 58 | .fuse_hidden* 59 | 60 | # KDE directory preferences 61 | .directory 62 | 63 | # Linux trash folder which might appear on any partition or disk 64 | .Trash-* 65 | 66 | # .nfs files are created when an open file is removed but is still being accessed 67 | .nfs* 68 | ### VisualStudio template 69 | ## Ignore Visual Studio temporary files, build results, and 70 | ## files generated by popular Visual Studio add-ons. 71 | ## 72 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 73 | 74 | # User-specific files 75 | *.suo 76 | *.user 77 | *.userosscache 78 | *.sln.docstates 79 | 80 | # User-specific files (MonoDevelop/Xamarin Studio) 81 | *.userprefs 82 | 83 | # Build results 84 | [Dd]ebug/ 85 | [Dd]ebugPublic/ 86 | [Rr]elease/ 87 | [Rr]eleases/ 88 | x64/ 89 | x86/ 90 | bld/ 91 | [Bb]in/ 92 | [Oo]bj/ 93 | [Ll]og/ 94 | 95 | # Visual Studio 2015 cache/options directory 96 | .vs/ 97 | # Uncomment if you have tasks that create the project's static files in wwwroot 98 | #wwwroot/ 99 | 100 | # MSTest test Results 101 | [Tt]est[Rr]esult*/ 102 | [Bb]uild[Ll]og.* 103 | 104 | # NUNIT 105 | *.VisualState.xml 106 | TestResult.xml 107 | 108 | # Build Results of an ATL Project 109 | [Dd]ebugPS/ 110 | [Rr]eleasePS/ 111 | dlldata.c 112 | 113 | # .NET Core 114 | project.lock.json 115 | project.fragment.lock.json 116 | artifacts/ 117 | **/Properties/launchSettings.json 118 | 119 | *_i.c 120 | *_p.c 121 | *_i.h 122 | *.ilk 123 | *.meta 124 | *.obj 125 | *.pch 126 | *.pdb 127 | *.pgc 128 | *.pgd 129 | *.rsp 130 | *.sbr 131 | *.tlb 132 | *.tli 133 | *.tlh 134 | *.tmp 135 | *.tmp_proj 136 | *.log 137 | *.vspscc 138 | *.vssscc 139 | .builds 140 | *.pidb 141 | *.svclog 142 | *.scc 143 | 144 | # Chutzpah Test files 145 | _Chutzpah* 146 | 147 | # Visual C++ cache files 148 | ipch/ 149 | *.aps 150 | *.ncb 151 | *.opendb 152 | *.opensdf 153 | *.sdf 154 | *.cachefile 155 | *.VC.db 156 | *.VC.VC.opendb 157 | 158 | # Visual Studio profiler 159 | *.psess 160 | *.vsp 161 | *.vspx 162 | *.sap 163 | 164 | # TFS 2012 Local Workspace 165 | $tf/ 166 | 167 | # Guidance Automation Toolkit 168 | *.gpState 169 | 170 | # ReSharper is a .NET coding add-in 171 | _ReSharper*/ 172 | *.[Rr]e[Ss]harper 173 | *.DotSettings.user 174 | 175 | # JustCode is a .NET coding add-in 176 | .JustCode 177 | 178 | # TeamCity is a build add-in 179 | _TeamCity* 180 | 181 | # DotCover is a Code Coverage Tool 182 | *.dotCover 183 | 184 | # Visual Studio code coverage results 185 | *.coverage 186 | *.coveragexml 187 | 188 | # NCrunch 189 | _NCrunch_* 190 | .*crunch*.local.xml 191 | nCrunchTemp_* 192 | 193 | # MightyMoose 194 | *.mm.* 195 | AutoTest.Net/ 196 | 197 | # Web workbench (sass) 198 | .sass-cache/ 199 | 200 | # Installshield output folder 201 | [Ee]xpress/ 202 | 203 | # DocProject is a documentation generator add-in 204 | DocProject/buildhelp/ 205 | DocProject/Help/*.HxT 206 | DocProject/Help/*.HxC 207 | DocProject/Help/*.hhc 208 | DocProject/Help/*.hhk 209 | DocProject/Help/*.hhp 210 | DocProject/Help/Html2 211 | DocProject/Help/html 212 | 213 | # Click-Once directory 214 | publish/ 215 | 216 | # Publish Web Output 217 | *.[Pp]ublish.xml 218 | *.azurePubxml 219 | # TODO: Comment the next line if you want to checkin your web deploy settings 220 | # but database connection strings (with potential passwords) will be unencrypted 221 | *.pubxml 222 | *.publishproj 223 | 224 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 225 | # checkin your Azure Web App publish settings, but sensitive information contained 226 | # in these scripts will be unencrypted 227 | PublishScripts/ 228 | 229 | # NuGet Packages 230 | *.nupkg 231 | # The packages folder can be ignored because of Package Restore 232 | **/packages/* 233 | # except build/, which is used as an MSBuild target. 234 | !**/packages/build/ 235 | # Uncomment if necessary however generally it will be regenerated when needed 236 | #!**/packages/repositories.config 237 | # NuGet v3's project.json files produces more ignorable files 238 | *.nuget.props 239 | *.nuget.targets 240 | 241 | # Microsoft Azure Build Output 242 | csx/ 243 | *.build.csdef 244 | 245 | # Microsoft Azure Emulator 246 | ecf/ 247 | rcf/ 248 | 249 | # Windows Store app package directories and files 250 | AppPackages/ 251 | BundleArtifacts/ 252 | Package.StoreAssociation.xml 253 | _pkginfo.txt 254 | 255 | # Visual Studio cache files 256 | # files ending in .cache can be ignored 257 | *.[Cc]ache 258 | # but keep track of directories ending in .cache 259 | !*.[Cc]ache/ 260 | 261 | # Others 262 | ClientBin/ 263 | ~$* 264 | *~ 265 | *.dbmdl 266 | *.dbproj.schemaview 267 | *.jfm 268 | *.pfx 269 | *.publishsettings 270 | orleans.codegen.cs 271 | 272 | # Since there are multiple workflows, uncomment next line to ignore bower_components 273 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 274 | #bower_components/ 275 | 276 | # RIA/Silverlight projects 277 | Generated_Code/ 278 | 279 | # Backup & report files from converting an old project file 280 | # to a newer Visual Studio version. Backup files are not needed, 281 | # because we have git ;-) 282 | _UpgradeReport_Files/ 283 | Backup*/ 284 | UpgradeLog*.XML 285 | UpgradeLog*.htm 286 | 287 | # SQL Server files 288 | *.mdf 289 | *.ldf 290 | 291 | # Business Intelligence projects 292 | *.rdl.data 293 | *.bim.layout 294 | *.bim_*.settings 295 | 296 | # Microsoft Fakes 297 | FakesAssemblies/ 298 | 299 | # GhostDoc plugin setting file 300 | *.GhostDoc.xml 301 | 302 | # Node.js Tools for Visual Studio 303 | .ntvs_analysis.dat 304 | node_modules/ 305 | 306 | # Typescript v1 declaration files 307 | typings/ 308 | 309 | # Visual Studio 6 build log 310 | *.plg 311 | 312 | # Visual Studio 6 workspace options file 313 | *.opt 314 | 315 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 316 | *.vbw 317 | 318 | # Visual Studio LightSwitch build output 319 | **/*.HTMLClient/GeneratedArtifacts 320 | **/*.DesktopClient/GeneratedArtifacts 321 | **/*.DesktopClient/ModelManifest.xml 322 | **/*.Server/GeneratedArtifacts 323 | **/*.Server/ModelManifest.xml 324 | _Pvt_Extensions 325 | 326 | # Paket dependency manager 327 | .paket/paket.exe 328 | paket-files/ 329 | 330 | # FAKE - F# Make 331 | .fake/ 332 | 333 | # JetBrains Rider 334 | .idea/ 335 | *.sln.iml 336 | 337 | # CodeRush 338 | .cr/ 339 | 340 | # Python Tools for Visual Studio (PTVS) 341 | __pycache__/ 342 | *.pyc 343 | 344 | # Cake - Uncomment if you are using it 345 | # tools/** 346 | # !tools/packages.config 347 | ### VirtualEnv template 348 | # Virtualenv 349 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 350 | .Python 351 | [Bb]in 352 | [Ii]nclude 353 | [Ll]ib 354 | [Ll]ib64 355 | [Ll]ocal 356 | [Ss]cripts 357 | pyvenv.cfg 358 | .venv 359 | pip-selfcheck.json 360 | ### Windows template 361 | # Windows thumbnail cache files 362 | Thumbs.db 363 | ehthumbs.db 364 | ehthumbs_vista.db 365 | 366 | # Folder config file 367 | Desktop.ini 368 | 369 | # Recycle Bin used on file shares 370 | $RECYCLE.BIN/ 371 | 372 | # Windows Installer files 373 | *.cab 374 | *.msi 375 | *.msm 376 | *.msp 377 | 378 | # Windows shortcuts 379 | *.lnk 380 | ### macOS template 381 | *.DS_Store 382 | .AppleDouble 383 | .LSOverride 384 | 385 | # Icon must end with two \r 386 | Icon 387 | 388 | 389 | # Thumbnails 390 | ._* 391 | 392 | # Files that might appear in the root of a volume 393 | .DocumentRevisions-V100 394 | .fseventsd 395 | .Spotlight-V100 396 | .TemporaryItems 397 | .Trashes 398 | .VolumeIcon.icns 399 | .com.apple.timemachine.donotpresent 400 | 401 | # Directories potentially created on remote AFP share 402 | .AppleDB 403 | .AppleDesktop 404 | Network Trash Folder 405 | Temporary Items 406 | .apdisk 407 | ### Xcode template 408 | # Xcode 409 | # 410 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 411 | 412 | ## Build generated 413 | build/ 414 | DerivedData/ 415 | 416 | ## Various settings 417 | *.pbxuser 418 | !default.pbxuser 419 | *.mode1v3 420 | !default.mode1v3 421 | *.mode2v3 422 | !default.mode2v3 423 | *.perspectivev3 424 | !default.perspectivev3 425 | xcuserdata/ 426 | 427 | ## Other 428 | *.moved-aside 429 | *.xccheckout 430 | *.xcscmblueprint 431 | ### Vim template 432 | # swap 433 | [._]*.s[a-v][a-z] 434 | [._]*.sw[a-p] 435 | [._]s[a-v][a-z] 436 | [._]sw[a-p] 437 | # session 438 | Session.vim 439 | # temporary 440 | .netrwhist 441 | *~ 442 | # auto-generated tag files 443 | tags 444 | ### Eclipse template 445 | 446 | .metadata 447 | bin/ 448 | tmp/ 449 | *.tmp 450 | *.bak 451 | *.swp 452 | *~.nib 453 | local.properties 454 | .settings/ 455 | .loadpath 456 | .recommenders 457 | 458 | # Eclipse Core 459 | .project 460 | 461 | # External tool builders 462 | .externalToolBuilders/ 463 | 464 | # Locally stored "Eclipse launch configurations" 465 | *.launch 466 | 467 | # PyDev specific (Python IDE for Eclipse) 468 | *.pydevproject 469 | 470 | # CDT-specific (C/C++ Development Tooling) 471 | .cproject 472 | 473 | # JDT-specific (Eclipse Java Development Tools) 474 | .classpath 475 | 476 | # Java annotation processor (APT) 477 | .factorypath 478 | 479 | # PDT-specific (PHP Development Tools) 480 | .buildpath 481 | 482 | # sbteclipse plugin 483 | .target 484 | 485 | # Tern plugin 486 | .tern-project 487 | 488 | # TeXlipse plugin 489 | .texlipse 490 | 491 | # STS (Spring Tool Suite) 492 | .springBeans 493 | 494 | # Code Recommenders 495 | .recommenders/ 496 | 497 | # Scala IDE specific (Scala & Java development for Eclipse) 498 | .cache-main 499 | .scala_dependencies 500 | .worksheet 501 | ### Emacs template 502 | # -*- mode: gitignore; -*- 503 | *~ 504 | \#*\# 505 | /.emacs.desktop 506 | /.emacs.desktop.lock 507 | *.elc 508 | auto-save-list 509 | tramp 510 | .\#* 511 | 512 | # Org-mode 513 | .org-id-locations 514 | *_archive 515 | 516 | # flymake-mode 517 | *_flymake.* 518 | 519 | # eshell files 520 | /eshell/history 521 | /eshell/lastdir 522 | 523 | # elpa packages 524 | /elpa/ 525 | 526 | # reftex files 527 | *.rel 528 | 529 | # AUCTeX auto folder 530 | /auto/ 531 | 532 | # cask packages 533 | .cask/ 534 | dist/ 535 | 536 | # Flycheck 537 | flycheck_*.el 538 | 539 | # server auth directory 540 | /server/ 541 | 542 | # projectiles files 543 | .projectile 544 | 545 | # directory configuration 546 | .dir-locals.el 547 | ### SublimeText template 548 | # cache files for sublime text 549 | *.tmlanguage.cache 550 | *.tmPreferences.cache 551 | *.stTheme.cache 552 | 553 | # workspace files are user-specific 554 | *.sublime-workspace 555 | 556 | # project files should be checked into the repository, unless a significant 557 | # proportion of contributors will probably not be using SublimeText 558 | # *.sublime-project 559 | 560 | # sftp configuration file 561 | sftp-config.json 562 | 563 | # Package control specific files 564 | Package Control.last-run 565 | Package Control.ca-list 566 | Package Control.ca-bundle 567 | Package Control.system-ca-bundle 568 | Package Control.cache/ 569 | Package Control.ca-certs/ 570 | Package Control.merged-ca-bundle 571 | Package Control.user-ca-bundle 572 | oscrypto-ca-bundle.crt 573 | bh_unicode_properties.cache 574 | 575 | # Sublime-github package stores a github token in this file 576 | # https://packagecontrol.io/packages/sublime-github 577 | GitHub.sublime-settings 578 | ### Python template 579 | # Byte-compiled / optimized / DLL files 580 | __pycache__/ 581 | *.py[cod] 582 | *$py.class 583 | 584 | # C extensions 585 | *.so 586 | 587 | # Distribution / packaging 588 | .Python 589 | env/ 590 | build/ 591 | develop-eggs/ 592 | dist/ 593 | downloads/ 594 | eggs/ 595 | .eggs/ 596 | lib/ 597 | lib64/ 598 | parts/ 599 | sdist/ 600 | var/ 601 | wheels/ 602 | *.egg-info/ 603 | .installed.cfg 604 | *.egg 605 | 606 | # PyInstaller 607 | # Usually these files are written by a python script from a template 608 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 609 | *.manifest 610 | *.spec 611 | 612 | # Installer logs 613 | pip-log.txt 614 | pip-delete-this-directory.txt 615 | 616 | # Unit test / coverage reports 617 | htmlcov/ 618 | .tox/ 619 | .coverage 620 | .coverage.* 621 | .cache 622 | nosetests.xml 623 | coverage.xml 624 | *,cover 625 | .hypothesis/ 626 | 627 | # Translations 628 | *.mo 629 | *.pot 630 | 631 | # Django stuff: 632 | *.log 633 | local_settings.py 634 | 635 | # Flask stuff: 636 | instance/ 637 | .webassets-cache 638 | 639 | # Scrapy stuff: 640 | .scrapy 641 | 642 | # Sphinx documentation 643 | docs/_build/ 644 | 645 | # PyBuilder 646 | target/ 647 | 648 | # Jupyter Notebook 649 | .ipynb_checkpoints 650 | 651 | # pyenv 652 | .python-version 653 | 654 | # celery beat schedule file 655 | celerybeat-schedule 656 | 657 | # SageMath parsed files 658 | *.sage.py 659 | 660 | # dotenv 661 | .env 662 | 663 | # virtualenv 664 | .venv 665 | venv/ 666 | ENV/ 667 | 668 | # Spyder project settings 669 | .spyderproject 670 | 671 | # Rope project settings 672 | .ropeproject 673 | 674 | .cache/ 675 | slacker_asyncio.egg-info/ 676 | *.code-workspace 677 | .vscode/settings.json 678 | -------------------------------------------------------------------------------- /.pytest_cache/v/cache/nodeids: -------------------------------------------------------------------------------- 1 | [ 2 | "tests/test_channels.py::TestUtils::test_get_channel_id", 3 | "tests/test_channels.py::TestUtils::test_get_channel_id_without_channel", 4 | "tests/test_utils.py::TestUtils::test_get_item_id_by_name" 5 | ] -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | 4 | env: 5 | - TOXENV=py36 6 | 7 | python: 8 | - 3.6 9 | install: 10 | - travis_retry pip install tox 11 | script: 12 | - travis_retry tox 13 | deploy: 14 | provider: pypi 15 | user: 16 | secure: jE3YGz6YeNG5j6nz4PK3HiUKcFPfkwc8n2MxKZw8V7DBlbk7tHWqnZuG49c9CGQ+sKNiru/fIHtfsqDDbqkbooArVg/+XVUKkP40D3HquiSuKh/TLmeHoWaRgbBFq0O45GNg+jWAfkR825CJf/y4cbqBZEozai2VrpVEHrNfmVkDKX9CCgv7FHQBxy6RAoyEvmXr1kr7JyEtUorRH6u+JLkvpqj+5Dd29Q+ecvVJsF2dCx5Yyw0TfsTDuVJ16sesrKZuB5nQ3A/APISlo+nOLOawNL9TI8Q9TmjghpjriAIxvQx7xLLPn+4cAG3KEyW4sVJE3v9GOH9lTDNum712sntb67DPt6zhCX4sKZndiD79p/XUQIApJngQC6oW62pjb3+n7LjQhL6A8H87Ag54oUSH5yP7jmQ5Js3x2eRFCdPgWE0p/B6vY+3Hey+Sg+gKlBWDGrzxPlcuAVLbm3XI8vYgyPt2qBhCQ0YB9lK1ELLmQW2FoV2AeLSsyZiBVrD1wUv2zQrOIRoMxQ9e6TxZs16ka+oXYmALP8JdGiBcT7r+J6t6r78z/Pn9aQAsWuM7tnYiWnC20kYlw2rqAj7N/morrB7hKO/CZGZDzQNklvUYYG1JRRcp6+kaKtBeLIoPHga0CekMZyLjqQYqmRxyjF+n0hLrFPCMQIumrEsL5XA= 17 | password: 18 | secure: hl9haOrfNGBH8liTW/geljd8m62UZIRPReVEo+h/hXnN+oi3is9RbfKakioEVVoqO9iPrf4QCzaKdue2VZ+bs4HFrTDfRnkgpvlCqY6ZZKmVg+ZNOSsd7grCJBO8hEHcTkIsOHKvoEAhdGj3J0EdyP3hgbnT/RrrA7v4IeB/TxZ4fUC8SXUURZd65wAqLQ4qmGfJAgcOefy9CewlwwhQkEZ2ulKnLlLY7QR4Tnv5uoGCzZzYxAOzGleneJGpXMYw68T9Sv7jP+Cfja5D3KnbRAcBbh9hEVgNdcoZyjxTCXr5ym2YJdh1WOsJZQUC46z9RaZAofrKDE1iqz/5Brb4PJLO8373gNWNtoDSw8on8noL3S3EfJEEYm5NIqQmBLu9EQ2PiX8GcGvdpQdbBVPPH9LTJ1vhAkZ7AQ3bNNi6hEEIEqqesz+zfS7jwMbVMWrgb8gIfy8o4taPM/4Is2YdSwK/SJjKStxZk99hOpQgAyVwWFoeccHu1D7EVW986wBqrgIIAJi3aXyQUhv3eInaw+jQuaccWSlSYB2KoEJ2sRhtdiKKxFU+P9ZOTGZnQ0P+BtXz0p+HD0vaW7pjeJpTzvg3AiguQCf8ONqdhjnRutBs21wxm9bl5oYYI7Up+qLu1TSm4nYDsws1U+e1eoSZRfuIj2cZaBaJtRoJ6HOrlDA= 19 | on: 20 | distributions: sdist bdist_wheel 21 | repo: gfreezy/slacker-asyncio 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, and 10 | distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by the 13 | copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all other 16 | entities that control, are controlled by, or are under common control with 17 | that entity. For the purposes of this definition, "control" means (i) the 18 | power, direct or indirect, to cause the direction or management of such 19 | entity, whether by contract or otherwise, or (ii) ownership of 20 | fifty percent (50%) or more of the outstanding shares, or (iii) beneficial 21 | ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity exercising 24 | permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation source, 28 | and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical transformation 31 | or translation of a Source form, including but not limited to compiled 32 | object code, generated documentation, and conversions to 33 | other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or Object 36 | form, made available under the License, as indicated by a copyright notice 37 | that is included in or attached to the work (an example is provided in the 38 | Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object form, 41 | that is based on (or derived from) the Work and for which the editorial 42 | revisions, annotations, elaborations, or other modifications represent, 43 | as a whole, an original work of authorship. For the purposes of this 44 | License, Derivative Works shall not include works that remain separable 45 | from, or merely link (or bind by name) to the interfaces of, the Work and 46 | Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including the original 49 | version of the Work and any modifications or additions to that Work or 50 | Derivative Works thereof, that is intentionally submitted to Licensor for 51 | inclusion in the Work by the copyright owner or by an individual or 52 | Legal Entity authorized to submit on behalf of the copyright owner. 53 | For the purposes of this definition, "submitted" means any form of 54 | electronic, verbal, or written communication sent to the Licensor or its 55 | representatives, including but not limited to communication on electronic 56 | mailing lists, source code control systems, and issue tracking systems 57 | that are managed by, or on behalf of, the Licensor for the purpose of 58 | discussing and improving the Work, but excluding communication that is 59 | conspicuously marked or otherwise designated in writing by the copyright 60 | owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity on 63 | behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. 67 | 68 | Subject to the terms and conditions of this License, each Contributor 69 | hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, 70 | royalty-free, irrevocable copyright license to reproduce, prepare 71 | Derivative Works of, publicly display, publicly perform, sublicense, 72 | and distribute the Work and such Derivative Works in 73 | Source or Object form. 74 | 75 | 3. Grant of Patent License. 76 | 77 | Subject to the terms and conditions of this License, each Contributor 78 | hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, 79 | royalty-free, irrevocable (except as stated in this section) patent 80 | license to make, have made, use, offer to sell, sell, import, and 81 | otherwise transfer the Work, where such license applies only to those 82 | patent claims licensable by such Contributor that are necessarily 83 | infringed by their Contribution(s) alone or by combination of their 84 | Contribution(s) with the Work to which such Contribution(s) was submitted. 85 | If You institute patent litigation against any entity (including a 86 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a 87 | Contribution incorporated within the Work constitutes direct or 88 | contributory patent infringement, then any patent licenses granted to 89 | You under this License for that Work shall terminate as of the date such 90 | litigation is filed. 91 | 92 | 4. Redistribution. 93 | 94 | You may reproduce and distribute copies of the Work or Derivative Works 95 | thereof in any medium, with or without modifications, and in Source or 96 | Object form, provided that You meet the following conditions: 97 | 98 | 1. You must give any other recipients of the Work or Derivative Works a 99 | copy of this License; and 100 | 101 | 2. You must cause any modified files to carry prominent notices stating 102 | that You changed the files; and 103 | 104 | 3. You must retain, in the Source form of any Derivative Works that You 105 | distribute, all copyright, patent, trademark, and attribution notices from 106 | the Source form of the Work, excluding those notices that do not pertain 107 | to any part of the Derivative Works; and 108 | 109 | 4. If the Work includes a "NOTICE" text file as part of its distribution, 110 | then any Derivative Works that You distribute must include a readable copy 111 | of the attribution notices contained within such NOTICE file, excluding 112 | those notices that do not pertain to any part of the Derivative Works, 113 | in at least one of the following places: within a NOTICE text file 114 | distributed as part of the Derivative Works; within the Source form or 115 | documentation, if provided along with the Derivative Works; or, within a 116 | display generated by the Derivative Works, if and wherever such 117 | third-party notices normally appear. The contents of the NOTICE file are 118 | for informational purposes only and do not modify the License. 119 | You may add Your own attribution notices within Derivative Works that You 120 | distribute, alongside or as an addendum to the NOTICE text from the Work, 121 | provided that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and may 125 | provide additional or different license terms and conditions for use, 126 | reproduction, or distribution of Your modifications, or for any such 127 | Derivative Works as a whole, provided Your use, reproduction, and 128 | distribution of the Work otherwise complies with the conditions 129 | stated in this License. 130 | 131 | 5. Submission of Contributions. 132 | 133 | Unless You explicitly state otherwise, any Contribution intentionally 134 | submitted for inclusion in the Work by You to the Licensor shall be under 135 | the terms and conditions of this License, without any additional 136 | terms or conditions. Notwithstanding the above, nothing herein shall 137 | supersede or modify the terms of any separate license agreement you may 138 | have executed with Licensor regarding such Contributions. 139 | 140 | 6. Trademarks. 141 | 142 | This License does not grant permission to use the trade names, trademarks, 143 | service marks, or product names of the Licensor, except as required for 144 | reasonable and customary use in describing the origin of the Work and 145 | reproducing the content of the NOTICE file. 146 | 147 | 7. Disclaimer of Warranty. 148 | 149 | Unless required by applicable law or agreed to in writing, Licensor 150 | provides the Work (and each Contributor provides its Contributions) 151 | on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 152 | either express or implied, including, without limitation, any warranties 153 | or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS 154 | FOR A PARTICULAR PURPOSE. You are solely responsible for determining the 155 | appropriateness of using or redistributing the Work and assume any risks 156 | associated with Your exercise of permissions under this License. 157 | 158 | 8. Limitation of Liability. 159 | 160 | In no event and under no legal theory, whether in tort 161 | (including negligence), contract, or otherwise, unless required by 162 | applicable law (such as deliberate and grossly negligent acts) or agreed 163 | to in writing, shall any Contributor be liable to You for damages, 164 | including any direct, indirect, special, incidental, or consequential 165 | damages of any character arising as a result of this License or out of 166 | the use or inability to use the Work (including but not limited to damages 167 | for loss of goodwill, work stoppage, computer failure or malfunction, 168 | or any and all other commercial damages or losses), even if such 169 | Contributor has been advised of the possibility of such damages. 170 | 171 | 9. Accepting Warranty or Additional Liability. 172 | 173 | While redistributing the Work or Derivative Works thereof, You may choose 174 | to offer, and charge a fee for, acceptance of support, warranty, 175 | indemnity, or other liability obligations and/or rights consistent with 176 | this License. However, in accepting such obligations, You may act only 177 | on Your own behalf and on Your sole responsibility, not on behalf of any 178 | other Contributor, and only if You agree to indemnify, defend, and hold 179 | each Contributor harmless for any liability incurred by, or claims 180 | asserted against, such Contributor by reason of your accepting any such 181 | warranty or additional liability. 182 | 183 | END OF TERMS AND CONDITIONS 184 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | Slacker-asyncio 3 | ================== 4 | |version|_ 5 | |pypi|_ 6 | |build status|_ 7 | |pypi downloads|_ 8 | 9 | About 10 | ===== 11 | 12 | Slacker-asyncio is a full-featured Python interface for the `Slack API 13 | `_. Slacker is a fork of `slacker `_ 14 | to asyncio. 15 | 16 | Examples 17 | ======== 18 | .. code-block:: python 19 | 20 | import asyncio 21 | from slacker import Slacker 22 | 23 | 24 | async def run(): 25 | with aiohttp.ClientSession() as session: 26 | slack = Slacker('', session=session) 27 | 28 | # Send a message to #general channel 29 | await slack.chat.post_message('#general', 'Hello fellow slackers!', as_user=True) 30 | 31 | # Get users list 32 | response = await slack.users.list() 33 | users = response.body['members'] 34 | 35 | # Upload a file 36 | await slack.files.upload('hello.txt') 37 | 38 | loop = asyncio.get_event_loop() 39 | loop.run_until_complete(run()) 40 | 41 | Installation 42 | ============ 43 | 44 | .. code-block:: bash 45 | 46 | $ pip install slacker-asyncio 47 | 48 | Documentation 49 | ============= 50 | 51 | https://api.slack.com/methods 52 | 53 | .. |version| image:: https://img.shields.io/pypi/pyversions/Slacker-asyncio.svg 54 | .. _version: https://pypi.python.org/pypi/slacker-asyncio/ 55 | .. |build status| image:: https://img.shields.io/travis/gfreezy/slacker-asyncio.svg 56 | .. _build status: http://travis-ci.org/gfreezy/slacker-asyncio 57 | .. |pypi| image:: https://img.shields.io/pypi/v/Slacker-asyncio.svg 58 | .. _pypi: https://pypi.python.org/pypi/slacker-asyncio/ 59 | .. |pypi downloads| image:: https://img.shields.io/pypi/dm/Slacker-asyncio.svg 60 | .. _pypi downloads: https://pypi.python.org/pypi/slacker-asyncio/ 61 | -------------------------------------------------------------------------------- /examples/list.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """List items in slack.""" 3 | 4 | # https://github.com/os/slacker 5 | # https://api.slack.com/methods 6 | 7 | import os 8 | import asyncio 9 | from slacker import Slacker 10 | 11 | 12 | async def list_slack(): 13 | """List channels & users in slack.""" 14 | try: 15 | token = os.environ['SLACK_TOKEN'] 16 | slack = Slacker(token) 17 | 18 | # Get channel list 19 | response = await slack.channels.list() 20 | channels = response.body['channels'] 21 | for channel in channels: 22 | print(channel['id'], channel['name']) 23 | # if not channel['is_archived']: 24 | # slack.channels.join(channel['name']) 25 | print() 26 | 27 | # # Get users list 28 | # response = await slack.users.list() 29 | # users = response.body['members'] 30 | # for user in users: 31 | # if not user['deleted']: 32 | # print(user['id'], user['name'], user['is_admin'], user[ 33 | # 'is_owner']) 34 | # print() 35 | await slack.close() 36 | except KeyError as ex: 37 | print('Environment variable %s not set.' % str(ex)) 38 | 39 | 40 | if __name__ == '__main__': 41 | loop = asyncio.get_event_loop() 42 | loop.run_until_complete(list_slack()) 43 | -------------------------------------------------------------------------------- /examples/post.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Post slack message.""" 3 | 4 | # https://github.com/os/slacker 5 | # https://api.slack.com/methods 6 | 7 | import os 8 | import asyncio 9 | from slacker import Slacker 10 | 11 | 12 | async def post_slack(): 13 | """Post slack message.""" 14 | try: 15 | token = os.environ['SLACK_TOKEN'] 16 | slack = Slacker(token) 17 | 18 | obj = await slack.chat.post_message( 19 | channel='#general', 20 | text='', 21 | as_user=True, 22 | attachments=[{"pretext": "Subject", 23 | "text": "Body"}]) 24 | print(obj.successful, obj.__dict__['body']['channel'], obj.__dict__[ 25 | 'body']['ts']) 26 | slack.close() 27 | except KeyError as ex: 28 | print('Environment variable %s not set.' % str(ex)) 29 | 30 | 31 | if __name__ == '__main__': 32 | loop = asyncio.get_event_loop() 33 | loop.run_until_complete(post_slack()) 34 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | mock~=2.0 2 | tox~=3.1 3 | tox-pyenv~=1.1 4 | pytest~=3.6 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | setup( 5 | name='slacker-asyncio', 6 | version='0.9.61', 7 | packages=['slacker'], 8 | description='Slack API asyncio client based on os\'s slacker', 9 | author='gfreezy', 10 | author_email='gfreezy@gmail.com', 11 | url='http://github.com/gfreezy/slacker-asyncio/', 12 | install_requires=['aiohttp ~= 3.3'], 13 | license='http://www.apache.org/licenses/LICENSE-2.0', 14 | test_suite='tests', 15 | classifiers=[ 16 | 'Intended Audience :: Developers', 17 | 'License :: OSI Approved :: Apache Software License', 18 | 'Natural Language :: English', 19 | 'Programming Language :: Python', 20 | 'Programming Language :: Python :: 3.6', 21 | ], 22 | keywords='slack api asyncio' 23 | ) 24 | -------------------------------------------------------------------------------- /slacker/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Oktay Sancak 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import json 16 | import time 17 | import aiohttp 18 | import asyncio 19 | from slacker.utils import get_item_id_by_name 20 | 21 | 22 | __version__ = '0.9.60' 23 | 24 | API_BASE_URL = 'https://slack.com/api/{api}' 25 | DEFAULT_TIMEOUT = 10 26 | DEFAULT_RETRIES = 0 27 | # seconds to wait after a 429 error if Slack's API doesn't provide one 28 | DEFAULT_WAIT = 20 29 | 30 | __all__ = ['Error', 'Response', 'BaseAPI', 'API', 'Auth', 'Users', 'Groups', 31 | 'Channels', 'Chat', 'IM', 'IncomingWebhook', 'Search', 'Files', 32 | 'Stars', 'Emoji', 'Presence', 'RTM', 'Team', 'Reactions', 'Pins', 33 | 'UserGroups', 'UserGroupsUsers', 'MPIM', 'OAuth', 'DND', 'Bots', 34 | 'FilesComments', 'Reminders', 'TeamProfile', 'UsersProfile', 35 | 'IDPGroups', 'Apps', 'AppsPermissions', 'Slacker'] 36 | 37 | 38 | class Error(Exception): 39 | pass 40 | 41 | 42 | class Response(object): 43 | 44 | def __init__(self, body): 45 | self.raw = body 46 | self.body = json.loads(body) 47 | self.successful = self.body['ok'] 48 | self.error = self.body.get('error') 49 | 50 | def __str__(self): 51 | return json.dumps(self.body) 52 | 53 | 54 | class BaseAPI(object): 55 | def __init__(self, token=None, timeout=DEFAULT_TIMEOUT, proxy=None, 56 | session=None, rate_limit_retries=DEFAULT_RETRIES): 57 | self.token_ = token 58 | self.timeout = timeout 59 | self.proxy = proxy 60 | self.session = session 61 | self.rate_limit_retries = rate_limit_retries 62 | 63 | async def _request(self, method, api, **kwargs): 64 | kwargs['data'] = {k: v for k, v in kwargs.get('data', {}).items() if v} 65 | kwargs['params'] = {k: v for k, v in kwargs.get('params', {}).items() if v} 66 | 67 | if self.token_: 68 | kwargs.setdefault('params', {})['token'] = self.token_ 69 | 70 | # while we have rate limit retries left, fetch the resource and back 71 | # off as Slack's HTTP response suggests 72 | for _ in range(self.rate_limit_retries): 73 | response = await method(API_BASE_URL.format(api=api), 74 | timeout=self.timeout, 75 | proxy=self.proxy, 76 | **kwargs) 77 | 78 | if response.status == 200: 79 | break 80 | 81 | # handle HTTP 429 as documented at 82 | # https://api.slack.com/docs/rate-limits 83 | elif response.status == 429: # HTTP 429 84 | time.sleep(int(response.headers.get('retry-after', DEFAULT_WAIT))) 85 | continue 86 | 87 | else: 88 | response.raise_for_status() 89 | 90 | else: 91 | # with no retries left, make one final attempt to fetch the resource, 92 | # but do not handle too_many status differently 93 | response = await method(API_BASE_URL.format(api=api), 94 | timeout=self.timeout, 95 | proxy=self.proxy, 96 | **kwargs) 97 | response.raise_for_status() 98 | 99 | text = await response.text() 100 | response = Response(text) 101 | if not response.successful: 102 | raise Error(response.error) 103 | 104 | return response 105 | 106 | def _session_get(self, url, params=None, **kwargs): 107 | return self.session.request( 108 | method='get', url=url, params=params, **kwargs 109 | ) 110 | 111 | def _session_post(self, url, data=None, **kwargs): 112 | return self.session.request( 113 | method='post', url=url, data=data, **kwargs 114 | ) 115 | 116 | def get(self, api, **kwargs): 117 | return self._request(self._session_get, api, **kwargs) 118 | 119 | def post(self, api, **kwargs): 120 | return self._request(self._session_post, api, **kwargs) 121 | 122 | 123 | class API(BaseAPI): 124 | def test(self, error=None, **kwargs): 125 | if error: 126 | kwargs['error'] = error 127 | 128 | return self.get('api.test', params=kwargs) 129 | 130 | 131 | class Auth(BaseAPI): 132 | def test(self): 133 | return self.get('auth.test') 134 | 135 | def revoke(self, test=True): 136 | return self.post('auth.revoke', data={'test': int(test)}) 137 | 138 | 139 | class UsersProfile(BaseAPI): 140 | def get(self, user=None, include_labels=False): 141 | return super(UsersProfile, self).get( 142 | 'users.profile.get', 143 | params={'user': user, 'include_labels': int(include_labels)} 144 | ) 145 | 146 | def set(self, user=None, profile=None, name=None, value=None): 147 | return self.post('users.profile.set', 148 | data={ 149 | 'user': user, 150 | 'profile': profile, 151 | 'name': name, 152 | 'value': value 153 | }) 154 | 155 | 156 | class UsersAdmin(BaseAPI): 157 | def invite(self, email, channels=None, first_name=None, 158 | last_name=None, resend=True): 159 | return self.post('users.admin.invite', 160 | params={ 161 | 'email': email, 162 | 'channels': channels, 163 | 'first_name': first_name, 164 | 'last_name': last_name, 165 | 'resend': resend 166 | }) 167 | 168 | 169 | class Users(BaseAPI): 170 | def __init__(self, *args, **kwargs): 171 | super(Users, self).__init__(*args, **kwargs) 172 | self._profile = UsersProfile(*args, **kwargs) 173 | self._admin = UsersAdmin(*args, **kwargs) 174 | 175 | @property 176 | def profile(self): 177 | return self._profile 178 | 179 | @property 180 | def admin(self): 181 | return self._admin 182 | 183 | def info(self, user): 184 | return self.get('users.info', params={'user': user}) 185 | 186 | def list(self, presence=False): 187 | return self.get('users.list', params={'presence': int(presence)}) 188 | 189 | def identity(self): 190 | return self.get('users.identity') 191 | 192 | def set_active(self): 193 | return self.post('users.setActive') 194 | 195 | def get_presence(self, user): 196 | return self.get('users.getPresence', params={'user': user}) 197 | 198 | def set_presence(self, presence): 199 | return self.post('users.setPresence', data={'presence': presence}) 200 | 201 | def get_user_id(self, user_name): 202 | members = self.list().body['members'] 203 | return get_item_id_by_name(members, user_name) 204 | 205 | 206 | class Groups(BaseAPI): 207 | def create(self, name): 208 | return self.post('groups.create', data={'name': name}) 209 | 210 | def create_child(self, channel): 211 | return self.post('groups.createChild', data={'channel': channel}) 212 | 213 | def info(self, channel): 214 | return self.get('groups.info', params={'channel': channel}) 215 | 216 | def list(self, exclude_archived=None): 217 | return self.get('groups.list', 218 | params={'exclude_archived': exclude_archived}) 219 | 220 | def history(self, channel, latest=None, oldest=None, count=None, 221 | inclusive=None): 222 | return self.get('groups.history', 223 | params={ 224 | 'channel': channel, 225 | 'latest': latest, 226 | 'oldest': oldest, 227 | 'count': count, 228 | 'inclusive': inclusive 229 | }) 230 | 231 | def invite(self, channel, user): 232 | return self.post('groups.invite', 233 | data={'channel': channel, 'user': user}) 234 | 235 | def kick(self, channel, user): 236 | return self.post('groups.kick', 237 | data={'channel': channel, 'user': user}) 238 | 239 | def leave(self, channel): 240 | return self.post('groups.leave', data={'channel': channel}) 241 | 242 | def mark(self, channel, ts): 243 | return self.post('groups.mark', data={'channel': channel, 'ts': ts}) 244 | 245 | def rename(self, channel, name): 246 | return self.post('groups.rename', 247 | data={'channel': channel, 'name': name}) 248 | 249 | def replies(self, channel, thread_ts): 250 | return self.get('groups.replies', 251 | params={'channel': channel, 'thread_ts': thread_ts}) 252 | 253 | def archive(self, channel): 254 | return self.post('groups.archive', data={'channel': channel}) 255 | 256 | def unarchive(self, channel): 257 | return self.post('groups.unarchive', data={'channel': channel}) 258 | 259 | def open(self, channel): 260 | return self.post('groups.open', data={'channel': channel}) 261 | 262 | def close(self, channel): 263 | return self.post('groups.close', data={'channel': channel}) 264 | 265 | def set_purpose(self, channel, purpose): 266 | return self.post('groups.setPurpose', 267 | data={'channel': channel, 'purpose': purpose}) 268 | 269 | def set_topic(self, channel, topic): 270 | return self.post('groups.setTopic', 271 | data={'channel': channel, 'topic': topic}) 272 | 273 | 274 | class Channels(BaseAPI): 275 | def create(self, name): 276 | return self.post('channels.create', data={'name': name}) 277 | 278 | def info(self, channel): 279 | return self.get('channels.info', params={'channel': channel}) 280 | 281 | def list(self, exclude_archived=None, exclude_members=None): 282 | return self.get('channels.list', 283 | params={'exclude_archived': exclude_archived, 284 | 'exclude_members': exclude_members}) 285 | 286 | def history(self, channel, latest=None, oldest=None, count=None, 287 | inclusive=False, unreads=False): 288 | return self.get('channels.history', 289 | params={ 290 | 'channel': channel, 291 | 'latest': latest, 292 | 'oldest': oldest, 293 | 'count': count, 294 | 'inclusive': int(inclusive), 295 | 'unreads': int(unreads) 296 | }) 297 | 298 | def mark(self, channel, ts): 299 | return self.post('channels.mark', 300 | data={'channel': channel, 'ts': ts}) 301 | 302 | def join(self, name): 303 | return self.post('channels.join', data={'name': name}) 304 | 305 | def leave(self, channel): 306 | return self.post('channels.leave', data={'channel': channel}) 307 | 308 | def invite(self, channel, user): 309 | return self.post('channels.invite', 310 | data={'channel': channel, 'user': user}) 311 | 312 | def kick(self, channel, user): 313 | return self.post('channels.kick', 314 | data={'channel': channel, 'user': user}) 315 | 316 | def rename(self, channel, name): 317 | return self.post('channels.rename', 318 | data={'channel': channel, 'name': name}) 319 | 320 | def replies(self, channel, thread_ts): 321 | return self.get('channels.replies', 322 | params={'channel': channel, 'thread_ts': thread_ts}) 323 | 324 | def archive(self, channel): 325 | return self.post('channels.archive', data={'channel': channel}) 326 | 327 | def unarchive(self, channel): 328 | return self.post('channels.unarchive', data={'channel': channel}) 329 | 330 | def set_purpose(self, channel, purpose): 331 | return self.post('channels.setPurpose', 332 | data={'channel': channel, 'purpose': purpose}) 333 | 334 | def set_topic(self, channel, topic): 335 | return self.post('channels.setTopic', 336 | data={'channel': channel, 'topic': topic}) 337 | 338 | def get_channel_id(self, channel_name): 339 | channels = self.list().body['channels'] 340 | return get_item_id_by_name(channels, channel_name) 341 | 342 | 343 | class Chat(BaseAPI): 344 | def post_message(self, channel, text=None, username=None, as_user=None, 345 | parse=None, link_names=None, attachments=None, 346 | unfurl_links=None, unfurl_media=None, icon_url=None, 347 | icon_emoji=None, thread_ts=None): 348 | # Ensure attachments are json encoded 349 | if attachments: 350 | if isinstance(attachments, list): 351 | attachments = json.dumps(attachments) 352 | 353 | return self.post('chat.postMessage', 354 | data={ 355 | 'channel': channel, 356 | 'text': text, 357 | 'username': username, 358 | 'as_user': as_user, 359 | 'parse': parse, 360 | 'link_names': link_names, 361 | 'attachments': attachments, 362 | 'unfurl_links': unfurl_links, 363 | 'unfurl_media': unfurl_media, 364 | 'icon_url': icon_url, 365 | 'icon_emoji': icon_emoji, 366 | 'thread_ts': thread_ts 367 | }) 368 | 369 | def me_message(self, channel, text): 370 | return self.post('chat.meMessage', 371 | data={'channel': channel, 'text': text}) 372 | 373 | def command(self, channel, command, text): 374 | return self.post('chat.command', 375 | data={ 376 | 'channel': channel, 377 | 'command': command, 378 | 'text': text 379 | }) 380 | 381 | def update(self, channel, ts, text, attachments=None, parse=None, 382 | link_names=False, as_user=None): 383 | # Ensure attachments are json encoded 384 | if attachments is not None and isinstance(attachments, list): 385 | attachments = json.dumps(attachments) 386 | return self.post('chat.update', 387 | data={ 388 | 'channel': channel, 389 | 'ts': ts, 390 | 'text': text, 391 | 'attachments': attachments, 392 | 'parse': parse, 393 | 'link_names': int(link_names), 394 | 'as_user': as_user, 395 | }) 396 | 397 | def delete(self, channel, ts, as_user=False): 398 | return self.post('chat.delete', 399 | data={ 400 | 'channel': channel, 401 | 'ts': ts, 402 | 'as_user': as_user 403 | }) 404 | 405 | def post_ephemeral(self, channel, text, user, as_user=None, 406 | attachments=None, link_names=None, parse=None): 407 | # Ensure attachments are json encoded 408 | if attachments is not None and isinstance(attachments, list): 409 | attachments = json.dumps(attachments) 410 | return self.post('chat.postEphemeral', 411 | data={ 412 | 'channel': channel, 413 | 'text': text, 414 | 'user': user, 415 | 'as_user': as_user, 416 | 'attachments': attachments, 417 | 'link_names': link_names, 418 | 'parse': parse, 419 | }) 420 | 421 | def unfurl(self, channel, ts, unfurls, user_auth_message=None, 422 | user_auth_required=False, user_auth_url=None): 423 | return self.post('chat.unfurl', 424 | data={ 425 | 'channel': channel, 426 | 'ts': ts, 427 | 'unfurls': unfurls, 428 | 'user_auth_message': user_auth_message, 429 | 'user_auth_required': user_auth_required, 430 | 'user_auth_url': user_auth_url, 431 | }) 432 | 433 | 434 | class IM(BaseAPI): 435 | def list(self): 436 | return self.get('im.list') 437 | 438 | def history(self, channel, latest=None, oldest=None, count=None, 439 | inclusive=None, unreads=False): 440 | return self.get('im.history', 441 | params={ 442 | 'channel': channel, 443 | 'latest': latest, 444 | 'oldest': oldest, 445 | 'count': count, 446 | 'inclusive': inclusive, 447 | 'unreads': int(unreads) 448 | }) 449 | 450 | def replies(self, channel, thread_ts): 451 | return self.get('im.replies', 452 | params={'channel': channel, 'thread_ts': thread_ts}) 453 | 454 | def mark(self, channel, ts): 455 | return self.post('im.mark', data={'channel': channel, 'ts': ts}) 456 | 457 | def open(self, user): 458 | return self.post('im.open', data={'user': user}) 459 | 460 | def close(self, channel): 461 | return self.post('im.close', data={'channel': channel}) 462 | 463 | 464 | class MPIM(BaseAPI): 465 | def open(self, users): 466 | if isinstance(users, (tuple, list)): 467 | users = ','.join(users) 468 | 469 | return self.post('mpim.open', data={'users': users}) 470 | 471 | def close(self, channel): 472 | return self.post('mpim.close', data={'channel': channel}) 473 | 474 | def mark(self, channel, ts): 475 | return self.post('mpim.mark', data={'channel': channel, 'ts': ts}) 476 | 477 | def list(self): 478 | return self.get('mpim.list') 479 | 480 | def history(self, channel, latest=None, oldest=None, inclusive=False, 481 | count=None, unreads=False): 482 | return self.get('mpim.history', 483 | params={ 484 | 'channel': channel, 485 | 'latest': latest, 486 | 'oldest': oldest, 487 | 'inclusive': int(inclusive), 488 | 'count': count, 489 | 'unreads': int(unreads) 490 | }) 491 | 492 | def replies(self, channel, thread_ts): 493 | return self.get('mpim.replies', 494 | params={'channel': channel, 'thread_ts': thread_ts}) 495 | 496 | 497 | class Search(BaseAPI): 498 | 499 | def all(self, query, sort=None, sort_dir=None, highlight=None, count=None, 500 | page=None): 501 | return self.get('search.all', 502 | params={ 503 | 'query': query, 504 | 'sort': sort, 505 | 'sort_dir': sort_dir, 506 | 'highlight': highlight, 507 | 'count': count, 508 | 'page': page 509 | }) 510 | 511 | def files(self, query, sort=None, sort_dir=None, highlight=None, 512 | count=None, page=None): 513 | return self.get('search.files', 514 | params={ 515 | 'query': query, 516 | 'sort': sort, 517 | 'sort_dir': sort_dir, 518 | 'highlight': highlight, 519 | 'count': count, 520 | 'page': page 521 | }) 522 | 523 | def messages(self, query, sort=None, sort_dir=None, highlight=None, 524 | count=None, page=None): 525 | return self.get('search.messages', 526 | params={ 527 | 'query': query, 528 | 'sort': sort, 529 | 'sort_dir': sort_dir, 530 | 'highlight': highlight, 531 | 'count': count, 532 | 'page': page 533 | }) 534 | 535 | 536 | class FilesComments(BaseAPI): 537 | def add(self, file_, comment): 538 | return self.post('files.comments.add', 539 | data={'file': file_, 'comment': comment}) 540 | 541 | def delete(self, file_, id): 542 | return self.post('files.comments.delete', 543 | data={'file': file_, 'id': id}) 544 | 545 | def edit(self, file_, id, comment): 546 | return self.post('files.comments.edit', 547 | data={'file': file_, 'id': id, 'comment': comment}) 548 | 549 | 550 | class Files(BaseAPI): 551 | def __init__(self, *args, **kwargs): 552 | super(Files, self).__init__(*args, **kwargs) 553 | self._comments = FilesComments(*args, **kwargs) 554 | 555 | @property 556 | def comments(self): 557 | return self._comments 558 | 559 | def list(self, user=None, ts_from=None, ts_to=None, types=None, 560 | count=None, page=None, channel=None): 561 | return self.get('files.list', 562 | params={ 563 | 'user': user, 564 | 'ts_from': ts_from, 565 | 'ts_to': ts_to, 566 | 'types': types, 567 | 'count': count, 568 | 'page': page, 569 | 'channel': channel 570 | }) 571 | 572 | def info(self, file_, count=None, page=None): 573 | return self.get('files.info', 574 | params={'file': file_, 'count': count, 'page': page}) 575 | 576 | def upload(self, file_=None, content=None, filetype=None, filename=None, 577 | title=None, initial_comment=None, channels=None): 578 | if isinstance(channels, (tuple, list)): 579 | channels = ','.join(channels) 580 | 581 | data = { 582 | 'content': content, 583 | 'filetype': filetype, 584 | 'filename': filename, 585 | 'title': title, 586 | 'initial_comment': initial_comment, 587 | 'channels': channels 588 | } 589 | 590 | if file_: 591 | with open(file_, 'rb') as f: 592 | return self.post('files.upload', data=data, files={'file': f}) 593 | else: 594 | return self.post('files.upload', data=data) 595 | 596 | def delete(self, file_): 597 | return self.post('files.delete', data={'file': file_}) 598 | 599 | def revoke_public_url(self, file_): 600 | return self.post('files.revokePublicURL', data={'file': file_}) 601 | 602 | def shared_public_url(self, file_): 603 | return self.post('files.sharedPublicURL', data={'file': file_}) 604 | 605 | 606 | class Stars(BaseAPI): 607 | def add(self, file_=None, file_comment=None, channel=None, timestamp=None): 608 | assert file_ or file_comment or channel 609 | 610 | return self.post('stars.add', 611 | data={ 612 | 'file': file_, 613 | 'file_comment': file_comment, 614 | 'channel': channel, 615 | 'timestamp': timestamp 616 | }) 617 | 618 | def list(self, user=None, count=None, page=None): 619 | return self.get('stars.list', 620 | params={'user': user, 'count': count, 'page': page}) 621 | 622 | def remove(self, file_=None, file_comment=None, channel=None, timestamp=None): 623 | assert file_ or file_comment or channel 624 | 625 | return self.post('stars.remove', 626 | data={ 627 | 'file': file_, 628 | 'file_comment': file_comment, 629 | 'channel': channel, 630 | 'timestamp': timestamp 631 | }) 632 | 633 | 634 | class Emoji(BaseAPI): 635 | def list(self): 636 | return self.get('emoji.list') 637 | 638 | 639 | class Presence(BaseAPI): 640 | AWAY = 'away' 641 | ACTIVE = 'active' 642 | TYPES = (AWAY, ACTIVE) 643 | 644 | def set(self, presence): 645 | assert presence in Presence.TYPES, 'Invalid presence type' 646 | return self.post('presence.set', data={'presence': presence}) 647 | 648 | 649 | class RTM(BaseAPI): 650 | def start(self, simple_latest=False, no_unreads=False, mpim_aware=False): 651 | return self.get('rtm.start', 652 | params={ 653 | 'simple_latest': int(simple_latest), 654 | 'no_unreads': int(no_unreads), 655 | 'mpim_aware': int(mpim_aware), 656 | }) 657 | 658 | def connect(self): 659 | return self.get('rtm.connect') 660 | 661 | 662 | class TeamProfile(BaseAPI): 663 | def get(self, visibility=None): 664 | return super(TeamProfile, self).get( 665 | 'team.profile.get', 666 | params={'visibility': visibility} 667 | ) 668 | 669 | 670 | class Team(BaseAPI): 671 | def __init__(self, *args, **kwargs): 672 | super(Team, self).__init__(*args, **kwargs) 673 | self._profile = TeamProfile(*args, **kwargs) 674 | 675 | @property 676 | def profile(self): 677 | return self._profile 678 | 679 | def info(self): 680 | return self.get('team.info') 681 | 682 | def access_logs(self, count=None, page=None): 683 | return self.get('team.accessLogs', 684 | params={'count': count, 'page': page}) 685 | 686 | def integration_logs(self, service_id=None, app_id=None, user=None, 687 | change_type=None, count=None, page=None): 688 | return self.get('team.integrationLogs', 689 | params={ 690 | 'service_id': service_id, 691 | 'app_id': app_id, 692 | 'user': user, 693 | 'change_type': change_type, 694 | 'count': count, 695 | 'page': page, 696 | }) 697 | 698 | def billable_info(self, user=None): 699 | return self.get('team.billableInfo', params={'user': user}) 700 | 701 | 702 | class Reactions(BaseAPI): 703 | def add(self, name, file_=None, file_comment=None, channel=None, 704 | timestamp=None): 705 | # One of file, file_comment, or the combination of channel and timestamp 706 | # must be specified 707 | assert (file_ or file_comment) or (channel and timestamp) 708 | 709 | return self.post('reactions.add', 710 | data={ 711 | 'name': name, 712 | 'file': file_, 713 | 'file_comment': file_comment, 714 | 'channel': channel, 715 | 'timestamp': timestamp, 716 | }) 717 | 718 | def get(self, file_=None, file_comment=None, channel=None, timestamp=None, 719 | full=None): 720 | return super(Reactions, self).get('reactions.get', 721 | params={ 722 | 'file': file_, 723 | 'file_comment': file_comment, 724 | 'channel': channel, 725 | 'timestamp': timestamp, 726 | 'full': full, 727 | }) 728 | 729 | def list(self, user=None, full=None, count=None, page=None): 730 | return super(Reactions, self).get('reactions.list', 731 | params={ 732 | 'user': user, 733 | 'full': full, 734 | 'count': count, 735 | 'page': page, 736 | }) 737 | 738 | def remove(self, name, file_=None, file_comment=None, channel=None, 739 | timestamp=None): 740 | # One of file, file_comment, or the combination of channel and timestamp 741 | # must be specified 742 | assert (file_ or file_comment) or (channel and timestamp) 743 | 744 | return self.post('reactions.remove', 745 | data={ 746 | 'name': name, 747 | 'file': file_, 748 | 'file_comment': file_comment, 749 | 'channel': channel, 750 | 'timestamp': timestamp, 751 | }) 752 | 753 | 754 | class Pins(BaseAPI): 755 | def add(self, channel, file_=None, file_comment=None, timestamp=None): 756 | # One of file, file_comment, or timestamp must also be specified 757 | assert file_ or file_comment or timestamp 758 | 759 | return self.post('pins.add', 760 | data={ 761 | 'channel': channel, 762 | 'file': file_, 763 | 'file_comment': file_comment, 764 | 'timestamp': timestamp, 765 | }) 766 | 767 | def remove(self, channel, file_=None, file_comment=None, timestamp=None): 768 | # One of file, file_comment, or timestamp must also be specified 769 | assert file_ or file_comment or timestamp 770 | 771 | return self.post('pins.remove', 772 | data={ 773 | 'channel': channel, 774 | 'file': file_, 775 | 'file_comment': file_comment, 776 | 'timestamp': timestamp, 777 | }) 778 | 779 | def list(self, channel): 780 | return self.get('pins.list', params={'channel': channel}) 781 | 782 | 783 | class UserGroupsUsers(BaseAPI): 784 | def list(self, usergroup, include_disabled=None): 785 | if isinstance(include_disabled, bool): 786 | include_disabled = int(include_disabled) 787 | 788 | return self.get('usergroups.users.list', params={ 789 | 'usergroup': usergroup, 790 | 'include_disabled': include_disabled, 791 | }) 792 | 793 | def update(self, usergroup, users, include_count=None): 794 | if isinstance(users, (tuple, list)): 795 | users = ','.join(users) 796 | 797 | if isinstance(include_count, bool): 798 | include_count = int(include_count) 799 | 800 | return self.post('usergroups.users.update', data={ 801 | 'usergroup': usergroup, 802 | 'users': users, 803 | 'include_count': include_count, 804 | }) 805 | 806 | 807 | class UserGroups(BaseAPI): 808 | def __init__(self, *args, **kwargs): 809 | super(UserGroups, self).__init__(*args, **kwargs) 810 | self._users = UserGroupsUsers(*args, **kwargs) 811 | 812 | @property 813 | def users(self): 814 | return self._users 815 | 816 | def list(self, include_disabled=None, include_count=None, include_users=None): 817 | if isinstance(include_disabled, bool): 818 | include_disabled = int(include_disabled) 819 | 820 | if isinstance(include_count, bool): 821 | include_count = int(include_count) 822 | 823 | if isinstance(include_users, bool): 824 | include_users = int(include_users) 825 | 826 | return self.get('usergroups.list', params={ 827 | 'include_disabled': include_disabled, 828 | 'include_count': include_count, 829 | 'include_users': include_users, 830 | }) 831 | 832 | def create(self, name, handle=None, description=None, channels=None, 833 | include_count=None): 834 | if isinstance(channels, (tuple, list)): 835 | channels = ','.join(channels) 836 | 837 | if isinstance(include_count, bool): 838 | include_count = int(include_count) 839 | 840 | return self.post('usergroups.create', data={ 841 | 'name': name, 842 | 'handle': handle, 843 | 'description': description, 844 | 'channels': channels, 845 | 'include_count': include_count, 846 | }) 847 | 848 | def update(self, usergroup, name=None, handle=None, description=None, 849 | channels=None, include_count=None): 850 | if isinstance(channels, (tuple, list)): 851 | channels = ','.join(channels) 852 | 853 | if isinstance(include_count, bool): 854 | include_count = int(include_count) 855 | 856 | return self.post('usergroups.update', data={ 857 | 'usergroup': usergroup, 858 | 'name': name, 859 | 'handle': handle, 860 | 'description': description, 861 | 'channels': channels, 862 | 'include_count': include_count, 863 | }) 864 | 865 | def disable(self, usergroup, include_count=None): 866 | if isinstance(include_count, bool): 867 | include_count = int(include_count) 868 | 869 | return self.post('usergroups.disable', data={ 870 | 'usergroup': usergroup, 871 | 'include_count': include_count, 872 | }) 873 | 874 | def enable(self, usergroup, include_count=None): 875 | if isinstance(include_count, bool): 876 | include_count = int(include_count) 877 | 878 | return self.post('usergroups.enable', data={ 879 | 'usergroup': usergroup, 880 | 'include_count': include_count, 881 | }) 882 | 883 | 884 | class DND(BaseAPI): 885 | def team_info(self, users=None): 886 | if isinstance(users, (tuple, list)): 887 | users = ','.join(users) 888 | 889 | return self.get('dnd.teamInfo', params={'users': users}) 890 | 891 | def set_snooze(self, num_minutes): 892 | return self.post('dnd.setSnooze', data={'num_minutes': num_minutes}) 893 | 894 | def info(self, user=None): 895 | return self.get('dnd.info', params={'user': user}) 896 | 897 | def end_dnd(self): 898 | return self.post('dnd.endDnd') 899 | 900 | def end_snooze(self): 901 | return self.post('dnd.endSnooze') 902 | 903 | 904 | class Reminders(BaseAPI): 905 | def add(self, text, time, user=None): 906 | return self.post('reminders.add', data={ 907 | 'text': text, 908 | 'time': time, 909 | 'user': user, 910 | }) 911 | 912 | def complete(self, reminder): 913 | return self.post('reminders.complete', data={'reminder': reminder}) 914 | 915 | def delete(self, reminder): 916 | return self.post('reminders.delete', data={'reminder': reminder}) 917 | 918 | def info(self, reminder): 919 | return self.get('reminders.info', params={'reminder': reminder}) 920 | 921 | def list(self): 922 | return self.get('reminders.list') 923 | 924 | 925 | class Bots(BaseAPI): 926 | def info(self, bot=None): 927 | return self.get('bots.info', params={'bot': bot}) 928 | 929 | 930 | class IDPGroups(BaseAPI): 931 | def list(self, include_users=False): 932 | return self.get('idpgroups.list', 933 | params={'include_users': int(include_users)}) 934 | 935 | 936 | class OAuth(BaseAPI): 937 | def access(self, client_id, client_secret, code, redirect_uri=None): 938 | return self.post('oauth.access', 939 | data={ 940 | 'client_id': client_id, 941 | 'client_secret': client_secret, 942 | 'code': code, 943 | 'redirect_uri': redirect_uri 944 | }) 945 | 946 | def token(self, client_id, client_secret, code, redirect_uri=None, 947 | single_channel=None): 948 | return self.post('oauth.token', 949 | data={ 950 | 'client_id': client_id, 951 | 'client_secret': client_secret, 952 | 'code': code, 953 | 'redirect_uri': redirect_uri, 954 | 'single_channel': single_channel, 955 | }) 956 | 957 | 958 | class AppsPermissions(BaseAPI): 959 | def info(self): 960 | return self.get('apps.permissions.info') 961 | 962 | def request(self, scopes, trigger_id): 963 | return self.post('apps.permissions.request', 964 | data={ 965 | scopes: ','.join(scopes), 966 | trigger_id: trigger_id, 967 | }) 968 | 969 | 970 | class Apps(BaseAPI): 971 | def __init__(self, *args, **kwargs): 972 | super(Apps, self).__init__(*args, **kwargs) 973 | self._permissions = AppsPermissions(*args, **kwargs) 974 | 975 | @property 976 | def permissions(self): 977 | return self._permissions 978 | 979 | 980 | class IncomingWebhook(object): 981 | def __init__(self, url=None, session=None, timeout=DEFAULT_TIMEOUT, proxy=None): 982 | self.url = url 983 | self.session = session 984 | self.timeout = timeout 985 | self.proxy = proxy 986 | 987 | def post(self, data): 988 | """ 989 | Posts message with payload formatted in accordance with 990 | this documentation https://api.slack.com/incoming-webhooks 991 | """ 992 | if not self.url: 993 | raise Error('URL for incoming webhook is undefined') 994 | 995 | return self.session.post(self.url, data=json.dumps(data), 996 | timeout=self.timeout, proxy=self.proxy) 997 | 998 | 999 | class Slacker(object): 1000 | oauth = OAuth(timeout=DEFAULT_TIMEOUT) 1001 | 1002 | def __init__(self, token, incoming_webhook_url=None, 1003 | timeout=DEFAULT_TIMEOUT, proxy=None, 1004 | session=None, rate_limit_retries=DEFAULT_RETRIES): 1005 | 1006 | if not session: 1007 | session = self.session = aiohttp.ClientSession() 1008 | 1009 | api_args = { 1010 | 'token': token, 1011 | 'timeout': timeout, 1012 | 'proxy': proxy, 1013 | 'session': session, 1014 | 'rate_limit_retries': rate_limit_retries, 1015 | } 1016 | self.im = IM(**api_args) 1017 | self.api = API(**api_args) 1018 | self.dnd = DND(**api_args) 1019 | self.rtm = RTM(**api_args) 1020 | self.apps = Apps(**api_args) 1021 | self.auth = Auth(**api_args) 1022 | self.bots = Bots(**api_args) 1023 | self.chat = Chat(**api_args) 1024 | self.team = Team(**api_args) 1025 | self.pins = Pins(**api_args) 1026 | self.mpim = MPIM(**api_args) 1027 | self.users = Users(**api_args) 1028 | self.files = Files(**api_args) 1029 | self.stars = Stars(**api_args) 1030 | self.emoji = Emoji(**api_args) 1031 | self.search = Search(**api_args) 1032 | self.groups = Groups(**api_args) 1033 | self.channels = Channels(**api_args) 1034 | self.presence = Presence(**api_args) 1035 | self.reminders = Reminders(**api_args) 1036 | self.reactions = Reactions(**api_args) 1037 | self.idpgroups = IDPGroups(**api_args) 1038 | self.usergroups = UserGroups(**api_args) 1039 | self.incomingwebhook = IncomingWebhook(url=incoming_webhook_url, session=session, 1040 | timeout=timeout, proxy=proxy) 1041 | 1042 | async def close(self): 1043 | self.session.close() -------------------------------------------------------------------------------- /slacker/utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | def get_item_id_by_name(list_dict, key_name): 6 | for d in list_dict: 7 | if d['name'] == key_name: 8 | return d['id'] 9 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gfreezy/slacker-asyncio/dd7cf5b5581b93101bff90144282c273a1e4dd28/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_channels.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | import json 4 | import unittest 5 | 6 | from mock import patch 7 | 8 | from slacker import Channels, Response 9 | 10 | 11 | class TestUtils(unittest.TestCase): 12 | 13 | @patch('slacker.BaseAPI._request') 14 | def test_get_channel_id(self, mock_request): 15 | text = { 16 | 'ok': 'true', 17 | 'channels': [ 18 | {'name': 'general', 'id': 'C111'}, 19 | {'name': 'random', 'id': 'C222'} 20 | ] 21 | } 22 | json_to_text = json.dumps(text) 23 | 24 | mock_request.return_value = Response( 25 | json_to_text, 26 | ) 27 | 28 | channels = Channels(token='aaa') 29 | 30 | self.assertEqual( 31 | 'C111', channels.get_channel_id('general') 32 | ) 33 | 34 | @patch('slacker.BaseAPI._request') 35 | def test_get_channel_id_without_channel(self, mock_request): 36 | text = { 37 | 'ok': 'true', 38 | 'channels': [ 39 | {'name': 'general', 'id': 'C111'}, 40 | {'name': 'random', 'id': 'C222'} 41 | ] 42 | } 43 | json_to_text = json.dumps(text) 44 | 45 | mock_request.return_value = Response( 46 | json_to_text, 47 | ) 48 | 49 | channels = Channels(token='aaa') 50 | 51 | self.assertEqual( 52 | None, channels.get_channel_id('fake_group') 53 | ) 54 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from slacker.utils import get_item_id_by_name 4 | 5 | 6 | class TestUtils(unittest.TestCase): 7 | 8 | def test_get_item_id_by_name(self): 9 | list_dict = [{'name': 'channel_name', 'id': '123'}, {}] 10 | 11 | self.assertEqual( 12 | '123', get_item_id_by_name(list_dict, 'channel_name') 13 | ) 14 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py36 8 | 9 | [testenv] 10 | deps = -r{toxinidir}/requirements-dev.txt 11 | commands = py.test tests/ 12 | --------------------------------------------------------------------------------