├── .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 |
--------------------------------------------------------------------------------