toot-0.25.2/0000755000175000017500000000000013612310376014165 5ustar ihabunekihabunek00000000000000toot-0.25.2/CHANGELOG.md0000644000175000017500000001476113612310143015777 0ustar ihabunekihabunek00000000000000Changelog --------- **0.25.2 (2020-01-23)** * Revert adding changelog and readme to sourceballs (#149) * TUI: Fall back to username when display_name is unset (thanks @dlax) * Note: 0.25.1 was skipped due to error when releasing **0.25.0 (2020-01-21)** * TUI: Show character count when composing (#121) * Include changelog and license in sourceballs (#133) * Fix searching by hashtag which include the '#' (#134) * Upgrade search to v2 (#135) * Fix compatibility with Python < 3.6 (don't use fstrings) **0.24.0 (2019-09-18)** * On Windows store config files under %APPDATA% * CLI: Don't use ANSI colors if not supported by terminal or when not in a tty * TUI: Implement deleting own status messages * TUI: Improve rendering of reblogged statuses (thanks @dlax) * TUI: Set urwid encoding to UTF-8 (thanks @bearzk) **0.23.1 (2019-09-04)** * Fix a date parsing bug in Python versions <3.7 (#114) **0.23.0 (2019-09-03)** * Add `toot tui`, new and improved TUI implemented written with the help of the [urwid](http://urwid.org/) library * Deprecate `toot curses`. It will show a deprecation notice when started. To be removed in a future release * Add `--editor` option to `toot post` to allow composing toots in an editor (#90) * Fix config file permissions, set them to 0600 when creating the initial config file (#109) * Add user agent string to all requests, fixes interaction with instances protected by Cloudflare (#106) **0.22.0 (2019-08-01)** * **BREAKING:** Dropped support for Python 3.3 * Add `toot notifications` to show notifications (thanks @dlax) * Add posting and replying to curses interface (thanks @Skehmatics) * Add `--language` option to `toot post` * Enable attaching upto 4 files via `--media` option on `toot post` **0.21.0 (2019-02-15)** * **BREAKING:** in `toot timeline` short argument for selecting a list is no longer `-i`, this has been changed to select the instance, so that it is the same as on other commands, please use the long form `--list` instead * Add `toot reblogged_by` to show who reblogged a status (#88) * Add `toot thread` to show a status with its replies (#87) * Better handling of wide characters (eastern scripts, emojis) (#84) * Improved `timeline`, nicer visuals, and it will now ask to show next batch of toots, unless given the `--once` option * Add public/local/tag timelines to `timeline` and `curses` * Support for boosting and favouriting in `toot curses`, press `f`/`b` (#88, #93) **0.20.0 (2019-02-01)** * Enable interaction with instances using http instead of https (#56) * Enable proxy usage via environment variables (#47) * Make `toot post` prompt for input if no text is given (#82) * Add post-related commands: `favourite`, `unfavourite`, `reblog`, `unreblog`, `pin` & `unpin` (#75) **0.19.0 (2018-06-27)** * Add support for replying to a toot (#6) * Add `toot delete` command for deleting a toot (#54) * Add global `--quiet` flag to silence output (#46) * Make `toot login` provide browser login, and `toot login_cli` log in via console. This makes it clear what's the preferred option. * Use Idempotency-Key header to prevent multiple toots being posted if request is retried * Fix a bug where all media would be marked as sensitive **0.18.0 (2018-06-12)** * Add support for public, tag and list timelines in `toot timeline` (#52) * Add `--sensitive` and `--spoiler-text` options to `toot post` (#63) * Curses app improvements (respect sensitive content, require keypress to show, add help modal, misc improvements) **0.17.1 (2018-01-15)** * Create config folder if it does not exist (#40) * Fix packaging to include `toot.ui` package (#41) **0.17.0 (2018-01-15)** * Changed configuration file format to allow switching between multiple logged in accounts (#32) * Respect XDG_CONFIG_HOME environment variable to locate config home (#12) * Dynamically calculate left window width, supports narrower windows (#27) * Redraw windows when terminal size changes (#25) * Support scrolling the status list * Fetch next batch of statuses when bottom is reached * Support up/down arrows (#30) * Misc visual improvements **0.16.2 (2018-01-02)** * No changes, pushed to fix a packaging issue **0.16.1 (2017-12-30)** * Fix bug with app registration **0.16.0 (2017-12-30)** * **BREAKING:** Dropped support for Python 2, because it's a pain to support and caused bugs with handling unicode. * Remove hacky `login_2fa` command, use `login_browser` instead * Add `instance` command * Allow `post`ing media without text (#24) **0.15.1 (2017-12-12)** * Fix crash when toot's URL is None (#33), thanks @veer66 **0.15.0 (2017-09-09)** * Fix Windows compatibility (#18) **0.14.0 (2017-09-07)** * Add `--debug` option to enable debug logging instead of using the `TOOT_DEBUG` environment variable. * Fix: don't read requirements.txt from setup.py, this fails when packaging deb and potentially in some other cases (see #18) **0.13.0 (2017-08-26)** * Allow passing `--instance` and `--email` to login command * Add `login_browser` command for proper two factor authentication through the browser (#19, #23) **0.12.0 (2017-05-08)** * Add option to disable ANSI color in output (#15) * Return nonzero error code on error (#14) * Change license to GPLv3 **0.11.0 (2017-05-07)** * Fix error when running toot from crontab (#11) * Minor tweaks **0.10.0 (2017-04-26)** * Add commands: `block`, `unblock`, `mute`, `unmute` * Internal improvements **0.9.1 (2017-04-24)** * Fix conflict with curses package name **0.9.0 (2017-04-21)** * Add `whois` command * Add experimental `curses` app for viewing the timeline **0.8.0 (2017-04-19)** * **BREAKING:** Renamed command `2fa` to `login_2fa` * It is now possible to pipe text into `toot post` **0.7.0 (2017-04-18)** * **WARNING:** Due to changes in configuration format, after upgrading to this version, you will be required to log in to your Mastodon instance again. * Experimental 2FA support (#3) * Do not create a new application for each login **0.6.0 (2017-04-17)** * Add `whoami` command * Migrate from `optparse` to `argparse` **0.5.0 (2017-04-16)** * Add `search`, `follow` and `unfollow` commands * Migrate from `optparse` to `argparse` **0.4.0 (2017-04-15)** * Add `upload` command to post media * Add `--visibility` and `--media` options to `post` command **0.3.0 (2017-04-13)** * Add: view timeline * Require an explicit login **0.2.1 (2017-04-13)** * Fix invalid requirements in setup.py **0.2.0 (2017-04-12)** * Bugfixes **0.1.0 (2017-04-12)** * Initial release toot-0.25.2/LICENSE0000644000175000017500000010451313142373330015173 0ustar ihabunekihabunek00000000000000 GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . toot-0.25.2/MANIFEST.in0000644000175000017500000000003113541646243015723 0ustar ihabunekihabunek00000000000000recursive-include tests *toot-0.25.2/Makefile0000644000175000017500000000074213541646243015636 0ustar ihabunekihabunek00000000000000.PHONY: clean publish test dist : python setup.py sdist --formats=gztar,zip python setup.py bdist_wheel --python-tag=py3 deb_dist: python setup.py --command-packages=stdeb.command bdist_deb publish : twine upload dist/*.tar.gz dist/*.whl test: pytest -v coverage: py.test --cov=toot --cov-report html tests/ clean : find . -name "*pyc" | xargs rm -rf $1 rm -rf build dist MANIFEST htmlcov deb_dist toot*.tar.gz changelog: ./scripts/generate_changelog > CHANGELOG.md toot-0.25.2/PKG-INFO0000644000175000017500000000210413612310376015257 0ustar ihabunekihabunek00000000000000Metadata-Version: 1.2 Name: toot Version: 0.25.2 Summary: Mastodon CLI client Home-page: https://github.com/ihabunek/toot/ Author: Ivan Habunek Author-email: ivan@habunek.com License: GPLv3 Project-URL: Documentation, https://toot.readthedocs.io/en/latest/ Project-URL: Issue tracker, https://github.com/ihabunek/toot/issues/ Description: Toot is a CLI and TUI tool for interacting with Mastodon instances from the command line. Allows posting text and media to the timeline, searching, following, muting and blocking accounts and other actions. Keywords: mastodon toot Platform: UNKNOWN Classifier: Development Status :: 4 - Beta Classifier: Environment :: Console :: Curses Classifier: Environment :: Console Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3) Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: 3.7 Requires-Python: >=3.4 toot-0.25.2/README.rst0000644000175000017500000000347013535213435015662 0ustar ihabunekihabunek00000000000000============================ Toot - a Mastodon CLI client ============================ .. image:: https://raw.githubusercontent.com/ihabunek/toot/master/trumpet.png Toot is a CLI and TUI tool for interacting with Mastodon instances from the command line. .. image:: https://img.shields.io/travis/ihabunek/toot.svg?maxAge=3600&style=flat-square :target: https://travis-ci.org/ihabunek/toot .. image:: https://img.shields.io/badge/author-%40ihabunek-blue.svg?maxAge=3600&style=flat-square :target: https://mastodon.social/@ihabunek .. image:: https://img.shields.io/github/license/ihabunek/toot.svg?maxAge=3600&style=flat-square :target: https://opensource.org/licenses/MIT .. image:: https://img.shields.io/pypi/v/toot.svg?maxAge=3600&style=flat-square :target: https://pypi.python.org/pypi/toot Resources --------- * Homepage: https://github.com/ihabunek/toot * Issues: https://github.com/ihabunek/toot/issues * Documentation: https://toot.readthedocs.io/en/latest/ * Discussion and support: #toot IRC channel on freenode.net (`webchat `_) Features -------- * Posting, replying, deleting statuses * Support for media uploads, spoiler text, sensitive content * Search by account or hash tag * Following, muting and blocking accounts * Simple switching between authenticated in Mastodon accounts Terminal User Interface ----------------------- toot includes a curses-based terminal user interface (TUI). Run it with ``toot tui``. .. image :: https://raw.githubusercontent.com/ihabunek/toot/master/docs/_static/tui_list.png .. image :: https://raw.githubusercontent.com/ihabunek/toot/master/docs/_static/tui_compose.png License ------- Copyright 2018 Ivan Habunek Licensed under `GPLv3 `_, see `LICENSE `_. toot-0.25.2/setup.cfg0000644000175000017500000000004613612310376016006 0ustar ihabunekihabunek00000000000000[egg_info] tag_build = tag_date = 0 toot-0.25.2/setup.py0000644000175000017500000000301213612310160015662 0ustar ihabunekihabunek00000000000000#!/usr/bin/env python from setuptools import setup long_description = """ Toot is a CLI and TUI tool for interacting with Mastodon instances from the command line. Allows posting text and media to the timeline, searching, following, muting and blocking accounts and other actions. """ setup( name='toot', version='0.25.2', description='Mastodon CLI client', long_description=long_description.strip(), author='Ivan Habunek', author_email='ivan@habunek.com', url='https://github.com/ihabunek/toot/', project_urls={ 'Documentation': 'https://toot.readthedocs.io/en/latest/', 'Issue tracker': 'https://github.com/ihabunek/toot/issues/', }, keywords='mastodon toot', license='GPLv3', classifiers=[ 'Development Status :: 4 - Beta', 'Environment :: Console :: Curses', 'Environment :: Console', 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', ], packages=['toot', 'toot.ui', 'toot.tui'], python_requires=">=3.4", install_requires=[ "requests>=2.13,<3.0", "beautifulsoup4>=4.5.0,<5.0", "wcwidth>=0.1.7,<2.0", "urwid>=2.0.0,<3.0", ], entry_points={ 'console_scripts': [ 'toot=toot.console:main', ], } ) toot-0.25.2/tests/0000755000175000017500000000000013612310376015327 5ustar ihabunekihabunek00000000000000toot-0.25.2/tests/__init__.py0000644000175000017500000000000013142373330017423 0ustar ihabunekihabunek00000000000000toot-0.25.2/tests/__pycache__/0000755000175000017500000000000013612310376017537 5ustar ihabunekihabunek00000000000000toot-0.25.2/tests/__pycache__/__init__.cpython-37.pyc0000644000175000017500000000021613612307261023723 0ustar ihabunekihabunek00000000000000B Y@sdS)Nrrr7/home/ihabunek/projects/ihabunek/toot/tests/__init__.pytoot-0.25.2/tests/__pycache__/test_api.cpython-37-pytest-5.1.2.pyc0000600000175000017500000000370113612307261025755 0ustar ihabunekihabunek00000000000000B te\@sddlZddlmmZddlZddlmZddl m Z m Z m Z ddl mZmZmZmZddlmZedddZedd d Zedd d ZdS) N)mock)App CLIENT_NAMECLIENT_WEBSITE) create_apploginSCOPESAuthenticationError) MockResponseztoot.http.anon_postcCs2tddd|_td|dtttdddS)Nfoobar) client_id client_secretzbigfish.softwarez$https://bigfish.software/api/v1/appszurn:ietf:wg:oauth:2.0:oob)ZwebsiteZ client_namescopesZ redirect_uris)r return_valuerassert_called_once_withrrr) mock_postr7/home/ihabunek/projects/ihabunek/toot/tests/test_api.pytest_create_app s rcCsXtdddd}d|j|jddtd}td d d d d |_t|dd|jd|dddS)Nzbigfish.softwarezhttps://bigfish.softwarer r passworduserpass) grant_typer rusernamerscopeZbearerzread write followZxxxiX) token_typerZ access_tokenZ created_atz$https://bigfish.software/oauth/tokenF)allow_redirects)rr rrr rrr)rappdatarrr test_logins  r c Csftdddd}d|j|jddtd}td d |_ttt |ddWdQRX|j d |d d dS)Nzbigfish.softwarezhttps://bigfish.softwarer r rrr)rr rrrrT)Z is_redirectz$https://bigfish.software/oauth/tokenF)r) rr rrr rpytestraisesr rr)rrrrrrtest_login_failed6s  r#)builtins @py_builtins_pytest.assertion.rewrite assertionrewrite @pytest_arr!unittestrtootrrrZtoot.apirrrr Z tests.utilsr Zpatchrr r#rrrrs  toot-0.25.2/tests/__pycache__/test_auth.cpython-37-pytest-5.1.2.pyc0000600000175000017500000001537413612307261026156 0ustar ihabunekihabunek00000000000000B oe\B@sfddlZddlmmZddlmZmZm Z m Z m Z ddl m Z ddZddZdd Zd d ZdS) N)AppUserapiconfigauth)retvalcCsbdddd}dd}|tdt||tdtd d d |td |td }||dS)Ndcidcs)id client_id client_secretc Sst|t}|dkr>ddlm}ddlm}||ddddd|sdd tksZt trdt tnd d tks|t |rt |nd d tkst trt tnd t |d }t t |d}|j }d }||k}|dkrddlm}ddlm}||ddddd|std|fd||fd tksHt |rRt |nd t |t |d}dd|i}t t |d}}}|j}d}||k}|dkrddlm}ddlm}||ddddd|sLtd|fd||fd tkst |rt |nd t |t |d}dd|i}t t |d}}}|j}d}||k}|dkrddlm}ddlm}||ddddd|std|fd||fd tkst |rt |nd t |t |d}dd|i}t t |d}}}|j}d}||k}|dkrfddlm}ddlm}||ddddd|std|fd||fd tkst |rt |nd t |t |d}dd|i}t t |d}}}dS)Nr)PytestAssertRewriteWarning) warn_explicitz5asserting the value None, please use "assert is None"z8/home/ihabunek/projects/ihabunek/toot/tests/test_auth.py )categoryfilenamelinenoz5assert %(py4)s {%(py4)s = %(py0)s(%(py1)s, %(py2)s) } isinstanceappr)py0py1py2py4zfoo.bar )==)z0%(py2)s {%(py2)s = %(py0)s.instance } == %(py5)s)rrpy5zassert %(py7)spy7zhttps://foo.bar )z0%(py2)s {%(py2)s = %(py0)s.base_url } == %(py5)sr )z1%(py2)s {%(py2)s = %(py0)s.client_id } == %(py5)sr )z5%(py2)s {%(py2)s = %(py0)s.client_secret } == %(py5)s)rr_pytest.warning_typesrwarningsr @py_builtinslocals @pytest_ar_should_repr_global_name _safereprAssertionError_format_explanationinstance_call_reprcomparebase_urlr r ) r @py_assert3rr @py_format5 @py_assert1 @py_assert4 @py_format6 @py_format8r38/home/ihabunek/projects/ihabunek/toot/tests/test_auth.py assert_app sr  x R   R   R   R z%test_register_app..assert_app create_app get_instancefoo1)titleversionZsave_appzfoo.bar)setattrrrrr register_app) monkeypatchZapp_datar5rr3r3r4test_register_apps  r?cCs|tdtdtd}d}||k}|dkr\ddlm}ddlm}||ddd d d |st d |fd ||fdt kst |rt |ndt |d}dd|i}tt |d}}dS)z)When there is saved config, it's returnedload_appz loaded appz bezdomni.netNr)r)rz5asserting the value None, please use "assert is None"z8/home/ihabunek/projects/ihabunek/toot/tests/test_auth.py)rrr)r)z%(py0)s == %(py3)sr)rpy3zassert %(py5)sr)r<rrrcreate_app_interactiver!rr"rr%r+r#r$r&r'r(r))r>r @py_assert2r/rr @py_format4r1r3r3r4test_create_app_from_configs  F rFcCs|tdtd|tdtdtd}d}||k}|dkrnddlm}ddlm}||d dd d d |st d |fd||fdt kst |rt |ndt |d}dd|i}tt |d}}dS)z6When there is no saved config, a new app is registeredr@Nr=zregistered appz bezdomni.netr)r)rz5asserting the value None, please use "assert is None"z8/home/ihabunek/projects/ihabunek/toot/tests/test_auth.py&)rrr)r)z%(py0)s == %(py3)sr)rrBzassert %(py5)sr)r<rrrrCr!rr"rr%r+r#r$r&r'r(r))r>rrDr/rrrEr1r3r3r4test_create_app_registered s  F rHcsTtddddd fdd }|td||td d d td }||dS)NTcs|dkr4ddlm}ddlm}||ddddd|srdd d tksRt|r\t|nd i}t t |t |t }|dkrddlm}ddlm}||dddd d|s@d d tkstt rtt nd dtkst|rt|nddtkstt r tt ndt|d}t t |d}|j }j }||k}|dkrddlm}ddlm}||ddddd|s&td|fd||fdtkst|rt|ndt|dtkstrtndt|d} dd| i} t t | d}}}|j}d} || k}|dkrzddlm}ddlm}||ddddd|std|fd|| fdtkst|rt|ndt|t| d} dd| i} t t | d}}} |j}d} || k}|dkr@ddlm}ddlm}||ddddd|std|fd || fdtkstt|r~t|ndt|t| d} dd| i} t t | d}}} dS)!Nr)r)rz5asserting the value None, please use "assert is None"z8/home/ihabunek/projects/ihabunek/toot/tests/test_auth.py-)rrrzassert %(py0)sractivate.z5assert %(py4)s {%(py4)s = %(py0)s(%(py1)s, %(py2)s) }ruserr)rrrr/)r)zN%(py2)s {%(py2)s = %(py0)s.instance } == %(py6)s {%(py6)s = %(py4)s.instance }r)rrrZpy6zassert %(py8)sZpy8r80)z0%(py2)s {%(py2)s = %(py0)s.username } == %(py5)s)rrrzassert %(py7)srabc1)z4%(py2)s {%(py2)s = %(py0)s.access_token } == %(py5)s)r!rr"rr#r$r%r&r'r(r)rrr*r+username access_token)rPrNrrZ @py_format1r-r.r/Z @py_assert5Z @py_format7Z @py_format9r0r1r2)rr3r4 assert_user,sh ,  | x   R   R z%test_create_user..assert_userZ save_userverify_credentialscSsddiS)NrUr8r3)xyr3r3r44z"test_create_user..rS)T)rr<rrrZ create_user)r>rWrPr3)rr4test_create_user)s  r])builtinsr#_pytest.assertion.rewrite assertionrewriter%tootrrrrr tests.utilsrr?rFrHr]r3r3r3r4s  toot-0.25.2/tests/__pycache__/test_config.cpython-37-pytest-5.1.2.pyc0000600000175000017500000004021213612307261026447 0ustar ihabunekihabunek00000000000000B te\;@sxddlZddlmmZddlZddlZddlm Z m Z m Z ej ddZ ddZddZd d Zd d Zd dZdS)N)UserAppconfigcCs2ddddddddd dd d d d d did dS)Nzhttps://foo.socialabcdefz foo.social)base_url client_id client_secretinstancezhttps://bar.socialghijklz bar.social)z foo.socialz bar.socialzfoo@bar.socialmnoihabunek) access_tokenr username)appsusers active_userrrr:/home/ihabunek/projects/ihabunek/toot/tests/test_config.py sample_configs rc Cs*t||d\}}t|t}|dkrRddlm}ddlm}||ddddd|sd d t ksnt trxt tnd d t kst |rt |nd d t kst trt tnd t |d }t t |d}|j}d}||k}|dkr(ddlm}ddlm}||ddddd|st d|fd||fd t ks\t |rft |nd t |t |d} dd| i} t t | d}}}|j}d}||k}|dkrddlm}ddlm}||ddddd|s`t d|fd||fd t ks"t |r,t |nd t |t |d} dd| i} t t | d}}}|j}d}||k}|dkrddlm}ddlm}||ddddd|s&t d|fd||fd t kst |rt |nd t |t |d} dd| i} t t | d}}}t|t}|dkrrddlm}ddlm}||ddddd|s d d t kst trt tnd dt kst |rt |nddt kst trt tndt |d }t t |d}|j}d}||k}|dkrVddlm}ddlm}||ddddd|st d|fd||fdt kst |rt |ndt |t |d} dd| i} t t | d}}}|j}d}||k}|dkrddlm}ddlm}||dddd d|st d|fd!||fdt ksPt |rZt |ndt |t |d} dd| i} t t | d}}}|j}d"}||k}|dkrddlm}ddlm}||dddd#d|sTt d|fd$||fdt kst |r t |ndt |t |d} dd| i} t t | d}}}|j}d%}||k}|dkrddlm}ddlm}||dddd&d|st d|fd'||fdt kst |rt |ndt |t |d} dd| i} t t | d}}}dS)(Nrr)PytestAssertRewriteWarning) warn_explicitz5asserting the value None, please use "assert is None"z:/home/ihabunek/projects/ihabunek/toot/tests/test_config.py&)categoryfilenamelinenoz5assert %(py4)s {%(py4)s = %(py0)s(%(py1)s, %(py2)s) } isinstanceuserr)py0py1py2py4z bar.social')==)z0%(py2)s {%(py2)s = %(py0)s.instance } == %(py5)s)rr!py5zassert %(py7)spy7r()z0%(py2)s {%(py2)s = %(py0)s.username } == %(py5)sr ))z4%(py2)s {%(py2)s = %(py0)s.access_token } == %(py5)s+appr,zhttps://bar.social-)z0%(py2)s {%(py2)s = %(py0)s.base_url } == %(py5)sr .)z1%(py2)s {%(py2)s = %(py0)s.client_id } == %(py5)sr /)z5%(py2)s {%(py2)s = %(py0)s.client_secret } == %(py5)s)rextract_user_apprr_pytest.warning_typesrwarningsr @py_builtinslocals @pytest_ar_should_repr_global_name _safereprAssertionError_format_explanationr _call_reprcomparerrrrrr ) rrr* @py_assert3rr @py_format5 @py_assert1 @py_assert4 @py_format6 @py_format8rrrtest_extract_active_user_app#s  x R   R   R    R   R   R   R r@c Cs>tj}d}|||}d}||k}|dkrTddlm}ddlm}||ddddd|std |fd ||fd t kst trt tnd t |d t kst |rt |nd t |t |t |d }dd|i} t t | d}}}}}tj}d}|||}d}||k}|dkr\ddlm}ddlm}||ddddd|std |fd ||fd t kst trt tnd t |d t kst |rt |nd t |t |t |d }dd|i} t t | d}}}}}d|ddd<tj}d}|||}d}||k}|dkr~ddlm}ddlm}||ddddd|s&td |fd ||fd t kst trt tnd t |d t kst |rt |nd t |t |t |d }dd|i} t t | d}}}}}dS)N)NNr)r)rz5asserting the value None, please use "assert is None"z:/home/ihabunek/projects/ihabunek/toot/tests/test_config.py4)rrr)r$)z`%(py7)s {%(py7)s = %(py2)s {%(py2)s = %(py0)s.extract_user_app }(%(py3)s, %(py5)s) } == %(py10)srr)rr!py3r%r&py10zassert %(py12)spy12zdoes-not-exist7rzfoo@bar.socialr ;)rr/r0rr1rr4r9r2r3r5r6r7r8) rr<r=Z @py_assert6 @py_assert9Z @py_assert8rrZ @py_format11 @py_format13rrr'test_extract_active_when_no_active_user2sV         rIcCs2tdddd}tdddd}t|d }d}|d }||k}|dkrpd d lm}d d lm}||d dddd|std|fd||ft|t|d} dd| i} t t | d}}}d}|d }||k}|dkrd d lm}d d lm}||d dddd|s\td|fd||ft|t|d} dd| i} t t | d}}}t j |||d } t| }d} || } || k}|dkrd d lm}d d lm}||d dddd|sptd|fd|| fdtksttrttndt| t|dtks:t|rDt|ndt| d}dd|i}t t |d} }}} } d}|d }||k}|dkrd d lm}d d lm}||d ddd d|std!|fd"||ft|t|d} dd| i} t t | d}}}|d dd#}d}||k}|dkrxd d lm}d d lm}||d ddd$d|std|fd%||ft|t|d} dd| i} t t | d}}}|d dd&}d}||k}|dkr"d d lm}d d lm}||d ddd'd|sntd|fd%||ft|t|d} dd| i} t t | d}}}|d dd(}d}||k}|dkrd d lm}d d lm}||d ddd)d|std|fd%||ft|t|d} dd| i} t t | d}}}|d dd*}d}||k}|dkrvd d lm}d d lm}||d ddd+d|std|fd%||ft|t|d} dd| i} t t | d}}}t j |||d } t| }d} || } || k}|dkr6d d lm}d d lm}||d ddd,d|std|fd|| fdtksjttrtttndt| t|dtkst|rt|ndt| d}dd|i}t t |d} }}} } d}|d }||k}|dkr4d d lm}d d lm}||d ddd-d|std!|fd"||ft|t|d} dd| i} t t | d}}}d}|d }||k}|dkrd d lm}d d lm}||d ddd.d|s"td!|fd"||ft|t|d} dd| i} t t | d}}}|d dd#}d}||k}|dkrd d lm}d d lm}||d ddd/d|std|fd%||ft|t|d} dd| i} t t | d}}}|d dd&}d}||k}|dk r*d d lm}d d lm}||d ddd0d| svtd|fd%||ft|t|d} dd| i} t t | d}}}|d dd(}d}||k}|dk rd d lm}d d lm}||d ddd1d| s td|fd%||ft|t|d} dd| i} t t | d}}}|d dd*}d}||k}|dk r~d d lm}d d lm}||d ddd2d| std|fd%||ft|t|d} dd| i} t t | d}}}|d dd#}d}||k}|dk r(d d lm}d d lm}||d ddd3d| sttd|fd%||ft|t|d} dd| i} t t | d}}}|d dd&}d}||k}|dk rd d lm}d d lm}||d ddd4d| std|fd%||ft|t|d} dd| i} t t | d}}}|d dd(}d}||k}|dk r|d d lm}d d lm}||d ddd5d| std|fd%||ft|t|d} dd| i} t t | d}}}|d dd*}d}||k}|dk r&d d lm}d d lm}||d ddd6d| srtd|fd%||ft|t|d} dd| i} t t | d}}}t j |||d } t| }d} || } || k}|dk rd d lm}d d lm}||d ddd7d|std|fd|| fdtksttr$ttndt| t|dtksPt|rZt|ndt| d}dd|i}t t |d} }}} } d}|d }||k}|dkrd d lm}d d lm}||d ddd8d|s0td!|fd"||ft|t|d} dd| i} t t | d}}}d}|d }||k}|dkrd d lm}d d lm}||d ddd9d|std!|fd"||ft|t|d} dd| i} t t | d}}}|d dd#}d}||k}|dkr0d d lm}d d lm}||d ddd:d|s|td|fd%||ft|t|d} dd| i} t t | d}}}|d dd&}d}||k}|dkrd d lm}d d lm}||d ddd;d|s&td|fd%||ft|t|d} dd| i} t t | d}}}|d dd(}d}||k}|dkrd d lm}d d lm}||d dddd|s$td|fd%||ft|t|d} dd| i} t t | d}}}|d dd&}d}||k}|dkrd d lm}d d lm}||d ddd?d|std|fd%||ft|t|d} dd| i} t t | d}}}|d dd(}d}||k}|dkr,d d lm}d d lm}||d ddd@d|sxtd|fd%||ft|t|d} dd| i} t t | d}}}|d dd*}d}||k}|dkrd d lm}d d lm}||d dddAd|s"td|fd%||ft|t|d} dd| i} t t | d}}}dS)BNzxxx.yyyzmoo.foorr)r)rz5asserting the value None, please use "assert is None"z:/home/ihabunek/projects/ihabunek/toot/tests/test_config.pyC)rrr)not in)z%(py1)s not in %(py4)s)r r"zassert %(py6)spy6DH)r$)z<%(py4)s {%(py4)s = %(py0)s(%(py2)s) } == (%(py6)s + %(py8)s)len app_count)rr!r"rRpy8zassert %(py11)spy11I)in)z%(py1)s in %(py4)sr J)z%(py1)s == %(py4)srKrLr MQRSTUVWXYZ[_`abcdefghi)rrVr0rr1rr4r9r6r7r8rsave_app __wrapped__r2r3r5)rr*Zapp2rW @py_assert0r: @py_assert2rrr; @py_format7r< @py_assert7rG @py_assert5 @py_format10 @py_format12rrr test_save_app>s  ,   ,     ,   ,   ,   ,   ,     ,   ,   ,   ,   ,   ,   ,   ,   ,   ,     ,   ,   ,   ,   ,   ,   ,   ,   ,   , rcCs8tdddd}t|d}d}|d}||k}|dkrbddlm}ddlm}||d dd d d |std |fd||ft|t|d}dd|i} t t | d}}}t j ||d}|d}||k}|dkrddlm}ddlm}||d dd dd |s\td|fd||ft|t|d}dd|i} t t | d}}}|d} t| }d} || } || k} | dkrddlm}ddlm}||d dd dd | sbtd| fd|| fdtksttrttndt| t|dtks,t|r6t|ndt| d}dd|i}t t |d} }} } } t j ||d}|d}||k}|dkrddlm}ddlm}||d dd dd |std|fd||ft|t|d}dd|i} t t | d}}}|d} t| }d} || } || k} | dkrddlm}ddlm}||d dd dd | s td| fd|| fdtksttrttndt| t|dtkst|rt|ndt| d}dd|i}t t |d} }} } } dS) Nz foo.socialrJrKrLrr)r)rz5asserting the value None, please use "assert is None"z:/home/ihabunek/projects/ihabunek/toot/tests/test_config.pyq)rrr)r[)z%(py1)s in %(py4)s)r r"zassert %(py6)srRt)rQ)z%(py1)s not in %(py4)srTu)r$)z<%(py4)s {%(py4)s = %(py0)s(%(py2)s) } == (%(py6)s - %(py8)s)rVrW)rr!r"rRrXzassert %(py11)srYyz)rrVr0rr1rr4r9r6r7r8r delete_apprwr2r3r5)rr*rWrxr:ryrrr;rzr<r{rGr|r}r~rrrtest_delete_appls  ,   ,     ,    rc Cs:tj}tdtjdd|}tj}|j}d}||}||k}|dkrxddlm }ddl m }||ddddd |s t d |fd ||fd tkst |rt |nd t |d tkst trt tnd t |t |t |t |d} dd| i} tt | d}}}}}}dtjd<|}d} || k}|dkrddlm }ddl m }||ddddd |st d |fd|| fd tkst |rt |nd t |t | d} dd| i} tt | d}}} dtjd<|}tj}|j}d}||}||k}|dkrnddlm }ddl m }||ddddd |st d |fd ||fd tkst |rt |nd t |d tkst trt tnd t |t |t |t |d} dd| i} tt | d}}}}}}dS)NXDG_CONFIG_HOMEz~/.config/toot/config.jsonr)r)rz5asserting the value None, please use "assert is None"z:/home/ihabunek/projects/ihabunek/toot/tests/test_config.py)rrr)r$)z%(py2)s {%(py2)s = %(py0)s() } == %(py12)s {%(py12)s = %(py8)s {%(py8)s = %(py6)s {%(py6)s = %(py4)s.path }.expanduser }(%(py10)s) }fnos)rr!r"rRrXrCrDzassert %(py14)sZpy14z/foo/bar/configz /foo/bar/config/toot/config.json)z)%(py2)s {%(py2)s = %(py0)s() } == %(py5)s)rr!r%zassert %(py7)sr&z ~/foo/configz~/foo/config/toot/config.json)rget_config_file_pathrunsetenvenvironpoppath expanduserr0rr1rr4r9r2r3r5r6r7r8)rr<r|r{rGZ @py_assert11r:rrrHZ @py_format15r=r>r?rrrtest_get_config_file_path}s^     R     r)builtinsr2_pytest.assertion.rewrite assertionrewriter4rpytesttootrrrfixturerr@rIrrrrrrrs .toot-0.25.2/tests/__pycache__/test_console.cpython-37-pytest-5.1.2.pyc0000600000175000017500000010413213612307261026646 0ustar ihabunekihabunek00000000000000B ߶&^(N@s`ddlZddlmmZddlZddlZddlZddl m Z ddl m Z ddl mZmZmZmZddlmZddlmZeddd d Zedd d Ze d dgZddZddZe de dddZe de dddZddZddZe dddZ e d d!d"Z!e d d#d$Z"e d d%d&Z#e d d'd(Z$e dd)d*Z%e d d+d,Z&e de d d-d.Z'e d d/d0Z(e de d d1d2Z)e d d3d4Z*e d d5d6Z+e d d7d8Z,e d d9d:Z-e dd;d<Z.dFd>d?Z/e d@e dAdBdCZ0e d@e dAdDdEZ1dS)GN) namedtuple)mock)consoleUserApphttp) ConsoleError) MockResponsez habunek.comzhttps://habunek.comfoobarzivan@habunek.comxxxMockUuidhexcCstdd|S)z)Remove ANSI color sequences from a stringz \x1b[^m]*m)resub)textr;/home/ihabunek/projects/ihabunek/toot/tests/test_console.py uncolorizesrc Cst|\}}d}||k}|dkrTddlm}ddlm}||ddddd|std |fd ||ft |d t kst |rt |nd d }d d|i}t t|d}}dS)Nztoot - a Mastodon CLI clientr)PytestAssertRewriteWarning) warn_explicitz5asserting the value None, please use "assert is None"z;/home/ihabunek/projects/ihabunek/toot/tests/test_console.py)categoryfilenamelineno)in)z%(py1)s in %(py3)sout)py1py3zassert %(py5)spy5)r print_usage readouterr_pytest.warning_typesrwarningsr @pytest_ar_call_reprcompare _saferepr @py_builtinslocals_should_repr_global_nameAssertionError_format_explanation) capsysrerr @py_assert0 @py_assert2rr @py_format4 @py_format6rrrtest_print_usages  F r3z uuid.uuid4ztoot.http.postc CsHtd|_tddi|_tttddg|jttddddddddd d did |\}}d }||k}|dkrd dl m }d dl m }||ddddd|st d|fd||ft |dtkst |rt |ndd} dd| i} tt | d}}d}||k}|dkrNd dl m }d dl m }||ddddd|st d|fd||ft |dtkst |rt |ndd} dd| i} tt | d}}| } | dkrd dl m }d dl m }||ddddd| s@dddtks t |r*t |ndi} tt | d} dS)Nzrock-onurlz(https://habunek.com/@ihabunek/1234567890postz Hello worldz/api/v1/statusespublicfalse)status visibilityz media_ids[] sensitive spoiler_textin_reply_to_idlanguagezIdempotency-Key)headersz Toot postedr)r)rz5asserting the value None, please use "assert is None"z;/home/ihabunek/projects/ihabunek/toot/tests/test_console.py4)rrr)r)z%(py1)s in %(py3)sr)rrzassert %(py5)sr 56zassert not %(py0)spy0r.)r return_valuer r run_commandappuserassert_called_once_withr"r#rr$rr%r&r'r(r)r*r+r,) mock_post mock_uuidr-rr.r/r0rrr1r2 @py_assert1 @py_format2rrrtest_post_defaultssZ      F  J  0rLc Cs^td|_dddddddd d d g }td d i|_tttd||jttdddddddd dddid|\}}d}||k}|dkrddl m }ddl m } | |ddddd|st d|fd||ft |dtkst |rt |ndd } d!d"| i} tt | d}}d }||k}|dkrdddl m }ddl m } | |dddd#d|st d|fd||ft |dtkst |rt |ndd } d!d"| i} tt | d}}| } | dkrddl m }ddl m } | |dddd$d| sVd%d&d'tks6t |r@t |nd'i} tt | d} dS)(Nz up-the-ironsz Hello worldz --visibilityZunlistedz --sensitivez--spoiler-textzSpoiler!z --reply-toZ123z --languageZhrvr4z(https://habunek.com/@ihabunek/1234567890r5z/api/v1/statusestrue{)r8z media_ids[]r9r:r;r<r=zIdempotency-Key)r>z Toot postedr)r)rz5asserting the value None, please use "assert is None"z;/home/ihabunek/projects/ihabunek/toot/tests/test_console.pyW)rrr)r)z%(py1)s in %(py3)sr)rrzassert %(py5)sr XYzassert not %(py0)srBr.)r rCr rrDrErFrGr"r#rr$rr%r&r'r(r)r*r+r,)rHrIr-argsrr.r/r0rrr1r2rJrKrrrtest_post_with_options9sf      F  J  0rSc Csdddg}tttttd|WdQRX|\}}d}||k}|dkr|ddlm }ddl m }||d dd d d |st d |fd||ft |dtkst |rt |ndd}dd|i} tt | d}}dS)Nz Hello worldz --visibilityr r5zinvalid visibility value: 'foo'r)r)rz5asserting the value None, please use "assert is None"z;/home/ihabunek/projects/ihabunek/toot/tests/test_console.pyc)rrr)r)z%(py1)s in %(py3)sr.)rrzassert %(py5)sr )pytestraises SystemExitrrDrErFr"r#rr$rr%r&r'r(r)r*r+r,) r-rRrr.r/r0rrr1r2rrrtest_post_invalid_visibility\s    F rXc Csdddg}tttttd|WdQRX|\}}d}||k}|dkr|ddlm }ddl m }||d dd d d |st d |fd||ft |dtkst |rt |ndd}dd|i} tt | d}}dS)Nz Hello worldz--mediazdoes_not_exist.jpgr5zcan't open 'does_not_exist.jpg'r)r)rz5asserting the value None, please use "assert is None"z;/home/ihabunek/projects/ihabunek/toot/tests/test_console.pym)rrr)r)z%(py1)s in %(py3)sr.)rrzassert %(py5)sr )rUrVrWrrDrErFr"r#rr$rr%r&r'r(r)r*r+r,) r-rRrr.r/r0rrr1r2rrrtest_post_invalid_mediafs    F rZztoot.http.deletec Cs`tttddg|ttd|\}}d}||k}|dkrlddlm}ddlm }||ddd d d |st d |fd ||ft |dt kst |rt |ndd}dd|i} tt | d}}| } | dkrddlm}ddlm }||ddd dd | sXdddt ks8t |rBt |ndi} tt | d} dS)NdeleteZ12321z/api/v1/statuses/12321zStatus deletedr)r)rz5asserting the value None, please use "assert is None"z;/home/ihabunek/projects/ihabunek/toot/tests/test_console.pyw)rrr)r)z%(py1)s in %(py3)sr)rrzassert %(py5)sr xzassert not %(py0)srBr.)rrDrErFrGr"r#rr$rr%r&r'r(r)r*r+r,) Z mock_deleter-rr.r/r0rrr1r2rJrKrrr test_deleteps.  F  0r^z toot.http.getc CsJtddddddddgdg|_tttdd g|ttd d|\}}|d }d}|d }||k}|dkrd dl m } d dl m } | | ddddd|st d|fd||ft |t |d} dd| i} tt | d}}}d}|d }||k}|dkrBd dl m } d dl m } | | ddddd|st d|fd||ft |t |d} dd| i} tt | d}}}d}|d }||k}|dkrd dl m } d dl m } | | ddddd|s0t d|fd||ft |t |d} dd| i} tt | d}}}d}||k}|dkr~d dl m } d dl m } | | ddddd|st d|fd||ft |d tkst |rt |nd d!} d"d#| i}tt |d}}d}|d$}||k}|dkr:d dl m } d dl m } | | dddd%d|st d|fd||ft |t |d} dd| i} tt | d}}}d&}||k}|dkrd dl m } d dl m } | | dddd'd|s>t d(|fd)||fd*tkst |rt |nd*t |d+} d"d#| i}tt |d}}dS),N111111111111111111uFrank Zappa 🎸fz) display_nameacctz2017-04-12T15:53:18.174Zz

The computer can't tell you the emotional story. It can give you the exact mathematical design, but what's missing is the eyebrows.

)idaccount created_atcontentreblogr<media_attachmentstimelinez--oncez/api/v1/timelines/home?limit=10 r)r)rz5asserting the value None, please use "assert is None"z;/home/ihabunek/projects/ihabunek/toot/tests/test_console.py)rrr)r)z%(py1)s in %(py4)s)rpy4zassert %(py6)spy6z@fzz2017-04-12 15:53zThe computer can't tell you the emotional story. It can give you the exact mathematical design, but what's missing is the eyebrows.)z%(py1)s in %(py3)sr)rrzassert %(py5)sr r)==)z%(py0)s == %(py3)sr.)rBr)r rCrrDrErFrGr"splitr#rr$rr%r&r'r+r,r(r)r*)mock_get monkeypatchr-rr.linesr/ @py_assert3r0rr @py_format5 @py_format7r1r2rJrrr test_timeline{s   ,   ,   ,   J  ,   J r}cCstdddddddddgd d gd g|_tttd d g|ttdd|\}}|d}d}|d}||k}|dkrddl m } ddl m } | | ddddd|st d|fd||ft |t |d} dd| i} tt | d}}}d}|d}||k}|dkrNddl m } ddl m } | | ddddd|st d|fd||ft |t |d} dd| i} tt | d}}}d}|d}||k}|dkrddl m } ddl m } | | dddd d|sThe computer can't tell you the emotional story. It can give you the exact mathematical design, but what's missing is the eyebrows.

)rdrfrh111111111111111110)rcrerdrgr<rhriz--oncez/api/v1/timelines/home?limit=10rjrkr)r)rz5asserting the value None, please use "assert is None"z;/home/ihabunek/projects/ihabunek/toot/tests/test_console.py)rrr)r)z%(py1)s in %(py4)s)rrmzassert %(py6)srnz@fzz2017-04-12 15:53zThe computer can't tell you the emotional story. It can give you the exact mathematical design, but what's missing is the eyebrows.)z%(py1)s in %(py3)sr)rrzassert %(py5)sr rru↻ Reblogged @jcr)ru)z%(py0)s == %(py3)sr.)rBr)r rCrrDrErFrGr"rvr#rr$rr%r&r'r+r,r(r)r*)rwrxr-rr.ryr/rzr0rrr{r|r1r2rJrrrtest_timeline_with_res   ,   ,   ,   J  ,   ,   J rc Cstddddddddgdtdddddd gddd gd ddddd gddd gd g|_tttddgtttdtttdg}|j|dd| \}}| }|dkrddl m }ddl m }||ddddd|s$dddtkst|rt|ndi} tt| d}|j}d } || } |j} d} | | }| |k}|dkrddl m }ddl m }||ddddd|sBtd|fd| |fd tkst|rt|nd t|t| t| d tkst|rt|nd t| t| t|d!}d"d#|i}tt|d}} } }} } }|j}d} || } |j} d } | | }| |k}|dkrddl m }ddl m }||dddd$d|sxtd|fd| |fd tkst|rt|nd t|t| t| d tks2t|rddl m }ddl m }||dddd,d|std&|fd'||ft|d tkszt|rt|nd d(}d)d*|i}tt|d}}d}||k}|dkrddl m }ddl m }||dddd-d|s\td&|fd'||ft|d tks.t|r8t|nd d(}d)d*|i}tt|d}}d.}||k}|dkrddl m }ddl m }||dddd/d|std&|fd'||ft|d tkst|rt|nd d(}d)d*|i}tt|d}}d}||k}|dkrZddl m }ddl m }||dddd0d|std&|fd'||ft|d tkst|rt|nd d(}d)d*|i}tt|d}}d1}||k}|dkrddl m }ddl m }||dddd2d|sxtd&|fd'||ft|d tksJt|rTt|nd d(}d)d*|i}tt|d}}dS)3Nr_z Frank Zappar`)rarbz2017-04-12T15:53:18.174Zzmy response in the middler~)rcrdrerfrgr<rhzoriginal content)rcrdrerfrhrgr<Z111111111111111112zresponse message)Z ancestors descendantsthreadz#/api/v1/statuses/111111111111111111z+/api/v1/statuses/111111111111111111/contextF) any_orderr)r)rz5asserting the value None, please use "assert is None"z;/home/ihabunek/projects/ihabunek/toot/tests/test_console.py)rrrzassert not %(py0)srBr.)<)z%(py6)s {%(py6)s = %(py2)s {%(py2)s = %(py0)s.index }(%(py4)s) } < %(py14)s {%(py14)s = %(py10)s {%(py10)s = %(py8)s.index }(%(py12)s) }r)rBpy2rmrnpy8py10py12py14zassert %(py16)sZpy16i)r)z%(py1)s in %(py3)s)rrzassert %(py5)sr iiiz@fziiz In reply toi)r side_effectrrDrErFrcallassert_has_callsr"r#rr$rr(r)r%r*r'r+r,indexr&)rwrxr-callsrr.rJrrrKrz @py_assert5 @py_assert9 @py_assert11Z @py_assert13 @py_assert7 @py_format15Z @py_format17r/r0r1r2rrr test_threads*   0     J  J  J  J  J  J  J rc Cs0tddddddg|_tttddgtttdg}|j|d d | \}}d dd dd dg}||k}|dkrddl m }ddl m} | |ddddd|s(td|fd||fdtkst|rt|nddtkst|rt|ndd} dd| i} tt| d}dS)Nz Terry Bozziozbozzio@drummers.social)rarbZDweezilzdweezil@zappafamily.social reblogged_byr_z0/api/v1/statuses/111111111111111111/reblogged_byF)rrjz @bozzio@drummers.socialz @dweezil@zappafamily.socialrr)r)rz5asserting the value None, please use "assert is None"z;/home/ihabunek/projects/ihabunek/toot/tests/test_console.pyi$)rrr)ru)z%(py0)s == %(py2)srexpected)rBrzassert %(py4)srm)r rCrrDrErFrrrr"joinr#rr$rr%r&r(r)r*r'r+r,) rwrxr-rrr.rrJrr @py_format3r{rrrtest_reblogged_by s2  b rcCs^tdddddd|_tttdtg|jdktj j \}}ttd f}||k}|dkrd d l m }d d l m}||d dddd|std|fd||fdtkst|rt|ndt|d}dd|i} tt| d}}|dd}tj} t|| } | dkrBd d l m }d d l m}||d dddd| sddtksdttrnttndt|dtksttrttndt| t| d} tt| d}} } |\} }d}|| k}|dkrd d l m }d d l m}||d dddd|std |fd!|| ft|d"tksZt| rdt| nd"d#}dd|i} tt| d}}t| k}|dkrd d l m }d d l m}||d ddd$d|sVtd |fd%t| fd&tksttr ttnd&d"tks(t| r2t| nd"d'}d(d)|i}tt|d}dS)*NrNz https://bigfish.software/123/456z https://bigfish.software/789/012z https://bigfish.software/345/678image)rcr4Z preview_urltext_urltypeZuploadrkz /api/v1/mediar)r)rz5asserting the value None, please use "assert is None"z;/home/ihabunek/projects/ihabunek/toot/tests/test_console.pyi5)rrr)ru)z%(py0)s == %(py3)srR)rBrzassert %(py5)sr filesfilei6zYassert %(py7)s {%(py7)s = %(py0)s(%(py2)s, %(py5)s {%(py5)s = %(py3)s.BufferedReader }) } isinstanceio)rBrrr py7zUploading mediai9)r)z%(py1)s in %(py3)sr)rri:)z%(py0)s in %(py2)s__file__)rBrzassert %(py4)srm)r rCrrDrErFr call_countrr5 call_argsr#rr$rr%r&r(r)r*r'r+r,rBufferedReaderrr")rHr-rRkwargsr0rJrrr1r2 @py_assert4 @py_assert6 @py_format8rr.r/rr{rrr test_upload&sj     F   n   J  h rc CsBtgdddgdddgdddgdd d d d d ggd |_tttddg|ttdddd|\}}d}||k}|dkrddlm }ddl m }||ddddd|st d|fd||ft |dtkst |rt |ndd}dd |i} tt | d}}d!}||k}|dkrdddlm }ddl m }||dddd"d|st d|fd||ft |dtkst |rt |ndd}dd |i} tt | d}}d#}||k}|dkrddlm }ddl m }||dddd$d|st d|fd||ft |dtksTt |r^t |ndd}dd |i} tt | d}}d%}||k}|dkrddlm }ddl m }||dddd&d|s6t d|fd||ft |dtkst |rt |ndd}dd |i} tt | d}}dS)'Nr z https://mastodon.social/tags/foo)historynamer4r z https://mastodon.social/tags/barZbazz https://mastodon.social/tags/bazZthequeenzFreddy Mercury)rbrazthequeen@other.instancezMercury Freddy)hashtagsaccountsZstatusessearchZfreddyz/api/v2/searchF)qresolvezHashtags: #foo, #bar, #bazr)r)rz5asserting the value None, please use "assert is None"z;/home/ihabunek/projects/ihabunek/toot/tests/test_console.pyic)rrr)r)z%(py1)s in %(py3)sr)rrzassert %(py5)sr z Accounts:idz@thequeen Freddy Mercuryiez'@thequeen@other.instance Mercury Freddyif)r rCrrDrErFrGr"r#rr$rr%r&r'r(r)r*r+r,) rwr-rr.r/r0rrr1r2rrr test_search=s     F  J  J  J rc Cstddddddg|_t|_tttddg|ttdddi|ttd |\}}d }||k}|dkrd d lm }d d l m }||ddddd|st d|fd||ft |dtkst |rt |ndd} dd| i} tt | d}}dS)NrNzblixa@other.acc)rcrbiAblixafollowz/api/v1/accounts/searchrz/api/v1/accounts/321/followzYou are now following blixar)r)rz5asserting the value None, please use "assert is None"z;/home/ihabunek/projects/ihabunek/toot/tests/test_console.pyix)rrr)r)z%(py1)s in %(py3)sr)rrzassert %(py5)sr )r rCrrDrErFrGr"r#rr$rr%r&r'r(r)r*r+r,) rwrHr-rr.r/r0rrr1r2rrr test_followis&  F rc Cs>t|_tt}tttddgWdQRX| ttdddid}|j }t |}||k}|dkrddl m }ddlm}||d dd d d |s*td |fd||ft|dtkstt rtt nddtkst|rt|ndt|t|d} dd| i} tt| d}}}}dS)Nrrz/api/v1/accounts/searchrzAccount not foundr)r)rz5asserting the value None, please use "assert is None"z;/home/ihabunek/projects/ihabunek/toot/tests/test_console.pyi)rrr)ru)zK%(py1)s == %(py8)s {%(py8)s = %(py3)s(%(py6)s {%(py6)s = %(py4)s.value }) }strex)rrrmrnrzassert %(py10)sr)r rCrUrVrrrDrErFrGvaluerr#rr$rr%r&r'r(r)r*r+r,) rwr-rr/rrr0rr @py_format9 @py_format11rrrtest_follow_not_found{s"  x rc Cstddddddg|_t|_tttddg|ttdddi|ttd |\}}d }||k}|dkrd d lm }d d l m }||ddddd|st d|fd||ft |dtkst |rt |ndd} dd| i} tt | d}}dS)NrNzblixa@other.acc)rcrbiArunfollowz/api/v1/accounts/searchrz/api/v1/accounts/321/unfollowz!You are no longer following blixar)r)rz5asserting the value None, please use "assert is None"z;/home/ihabunek/projects/ihabunek/toot/tests/test_console.pyi)rrr)r)z%(py1)s in %(py3)sr)rrzassert %(py5)sr )r rCrrDrErFrGr"r#rr$rr%r&r'r(r)r*r+r,) rwrHr-rr.r/r0rrr1r2rrr test_unfollows&  F rc Cs@tg|_tt}tttddgWdQRX| ttdddid}|j }t |}||k}|dkrddl m }ddlm}||d dd d d |s,td |fd||ft|dtkstt rtt nddtkst|rt|ndt|t|d} dd| i} tt| d}}}}dS)Nrrz/api/v1/accounts/searchrzAccount not foundr)r)rz5asserting the value None, please use "assert is None"z;/home/ihabunek/projects/ihabunek/toot/tests/test_console.pyi)rrr)ru)zK%(py1)s == %(py8)s {%(py8)s = %(py3)s(%(py6)s {%(py6)s = %(py4)s.value }) }rr)rrrmrnrzassert %(py10)sr)r rCrUrVrrrDrErFrGrrr#rr$rr%r&r'r(r)r*r+r,) rwr-rr/rrr0rrrrrrrtest_unfollow_not_founds"   x rc Cstddddddddddd d d d dd |_tttdg|ttd|\}}t|}d}||k}|dkrddl m }ddl m }||ddddd|st d|fd||ft |dtkst |rt |ndd}dd|i} tt | d}}d }||k}|dkrLddl m }ddl m }||ddddd|st d|fd||ft |dtkst |rt |ndd}dd|i} tt | d}}d }||k}|dkrddl m }ddl m }||ddddd|sjt d|fd||ft |dtkst ks"t|r,t|nd>t| t| t| t| d?} d@dA| i}tt|d}} } } } dS)BN1rz2019-02-16T07:01:20.714Zz Frank Zappazfrank@zappa.social)rarb)rcrrerd2mentionz2017-01-12T12:12:12.0Zz Dweezil Zappazdweezil@zappa.socialr_z2017-04-12T15:53:18.174Zz)

We still have fans in 2017 @fan123

)rcrdrerfrgr<rh)rcrrerdr83rgz1983-11-03T03:03:03.333Zz Terry Bozziozterry@bozzio.socialZ1234z Zappa Fanzfan123@zappa-fans.socialz1983-11-04T15:53:18.174Zz$

The Black Page, a masterpiece

4 favouritez1983-12-13T01:02:03.444Zz Zappa Old Fanzfan9@zappa-fans.social notificationsz/api/v1/notificationsdr)r)rz5asserting the value None, please use "assert is None"z;/home/ihabunek/projects/ihabunek/toot/tests/test_console.pyi)rrrzassert not %(py0)srBr.rju─z/Frank Zappa @frank@zappa.social now follows youz4Dweezil Zappa @dweezil@zappa.social mentioned you inzdDweezil Zappa @dweezil@zappa.social 2017-04-12 15:53rz"We still have fans in 2017 @fan123zID 111111111111111111 z7Terry Bozzio @terry@bozzio.social reblogged your statuszdZappa Fan @fan123@zappa-fans.social 1983-11-04 15:53zThe Black Page, a masterpiecez ID 1234 zsT      "   #*A,  %n toot-0.25.2/tests/__pycache__/test_utils.cpython-37-pytest-5.1.2.pyc0000600000175000017500000006000713612307261026346 0ustar ihabunekihabunek00000000000000B {e\@svddlZddlmmZddlmZmZm Z m Z ddZ ddZ ddZ d d Zd d Zd dZddZddZdS)N)wc_wraptruncpadfit_textc Csr d}d}| }t||}||k}|dkrTddlm}ddlm}||ddddd |std |fd ||fd tkst trt tnd d tkst |rt |nd t |t |d tkst |rt |nd d}dd|i}t t |d}}}}d}t||}||k} | dkrbddlm}ddlm}||ddddd | s td | fd||fd tkst trt tnd d tkst |rt |nd t |t |d tkst |rt |nd d} dd| i} t t | d}}} d}t||}||k} | dkrxddlm}ddlm}||ddddd | s6td | fd||fd tkst trt tnd d tkst |rt |nd t |t |d tkst |rt |nd d} dd| i} t t | d}}} d}t||}||k} | dkrddlm}ddlm}||ddddd | sLtd | fd||fd tkst trt tnd d tkst |rt |nd t |t |d tkst |r(t |nd d} dd| i} t t | d}}} d}t||}||k} | dkrddlm}ddlm}||ddddd | sbtd | fd||fd tkst trt tnd d tkst |rt |nd t |t |d tks4t |r>t |nd d} dd| i} t t | d}}} d}t||}d}||k} | dkrddlm}ddlm}||ddddd | s^td| fd||fd tkst trt tnd d tkst |r"t |nd t |t |t |d}dd|i}t t |d}}} }d}t||}d }||k} | dkrddlm}ddlm}||dddd!d | s^td| fd||fd tkst trt tnd d tkst |r"t |nd t |t |t |d}dd|i}t t |d}}} }d"}t||}d#}||k} | dkrddlm}ddlm}||dddd$d | s^td| fd||fd tkst trt tnd d tkst |r"t |nd t |t |t |d}dd|i}t t |d}}} }d}t||}d%}||k} | dkrddlm}ddlm}||dddd&d | s^td| fd||fd tkst trt tnd d tk st | r"t |nd t |t |t |d}dd|i}t t |d}}} }d!}t||}d'}||k} | dk rddlm}ddlm}||dddd(d | s^td| fd||fd tk st t rt tnd d tk st | r"t |nd t |t |t |d}dd|i}t t |d}}} }d$}t||}d)}||k} | dk rddlm}ddlm}||dddd*d | s^td| fd||fd tk st t rt tnd d tk st | r"t |nd t |t |t |d}dd|i}t t |d}}} }dS)+NuFrank Zappa 🎸dr)PytestAssertRewriteWarning) warn_explicitz5asserting the value None, please use "assert is None"z9/home/ihabunek/projects/ihabunek/toot/tests/test_utils.py )categoryfilenamelineno)is)z:%(py6)s {%(py6)s = %(py0)s(%(py1)s, -%(py3)s) } is %(py8)srtext)py0py1py3py6py8zassert %(py10)spy10 )z9%(py5)s {%(py5)s = %(py0)s(%(py1)s, %(py3)s) } is %(py7)s)rrrpy5py7zassert %(py9)spy9 uFrank Zappa 🎸 )==)z9%(py5)s {%(py5)s = %(py0)s(%(py1)s, %(py3)s) } == %(py8)s)rrrrruFrank Zappa 🎸 uFrank Zappa 🎸 uFrank Zappa 🎸 uFrank Zappa 🎸 uFrank Zappa 🎸 ) r_pytest.warning_typesrwarningsr @pytest_ar_call_reprcompare @py_builtinslocals_should_repr_global_name _safereprAssertionError_format_explanation) r @py_assert2 @py_assert4 @py_assert5 @py_assert7rr @py_format9 @py_format11 @py_assert6 @py_format8 @py_format10r99/home/ihabunek/projects/ihabunek/toot/tests/test_utils.pytest_pads                                     r;c Csd}d}t||}d}||k}|dkrRddlm}ddlm}||dddd d |std |fd ||fd tkst trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}d}t||}d}||k}|dkrHddlm}ddlm}||ddddd |std |fd ||fd tks|t trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}d}t||}d}||k}|dkrHddlm}ddlm}||ddddd |std |fd ||fd tks|t trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}d}t||}d}||k}|dkrHddlm}ddlm}||ddddd |std |fd ||fd tks|t trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}d}t||}d}||k}|dkrHddlm}ddlm}||ddddd |std |fd ||fd tks|t trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}d}t||}d}||k}|dkrHddlm}ddlm}||dddd d |std |fd ||fd tks|t trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}d!}t||}d}||k}|dkrHddlm}ddlm}||dddd"d |std |fd ||fd tks|t trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}d#}t||}d$}||k}|dkrHddlm}ddlm}||dddd%d |std |fd ||fd tks|t trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}d&}t||}d'}||k}|dkrHddlm}ddlm}||dddd(d |std |fd ||fd tks|t trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}d)}t||}d*}||k}|dk rHddlm}ddlm}||dddd+d | std |fd ||fd tk s|t t rt tnd dtk st | rt |ndt |t |t |d}dd|i}t t |d}}}}d,}t||}d-}||k}|dk rHddlm}ddlm}||dddd.d | std |fd ||fd tk s|t t rt tnd dtk st | rt |ndt |t |t |d}dd|i}t t |d}}}}d/}t||}d0}||k}|dk rHddlm}ddlm}||dddd1d | std |fd ||fd tk s|t t rt tnd dtk st | rt |ndt |t |t |d}dd|i}t t |d}}}}d2}t||}d0}||k}|dk rHddlm}ddlm}||dddd3d | std |fd ||fd tk s|t t rt tnd dtk st | rt |ndt |t |t |d}dd|i}t t |d}}}}d4}t||}||k}|dk rDddlm}ddlm}||dddd5d |std6|fd7||fd tk sxt t rt tnd dtk st | rt |ndt |t |dtk st | rt |ndd8} d9d:| i} t t | d}}}d;}t||}||k}|dkrZddlm}ddlm}||ddddd |s.td6|fd7||fd tkst trt tnd dtkst |rt |ndt |t |dtkst |r t |ndd8} d9d:| i} t t | d}}}d?}t||}||k}|dkrddlm}ddlm}||dddd@d |sDtd6|fd7||fd tkst trt tnd dtkst |rt |ndt |t |dtkst |r t |ndd8} d9d:| i} t t | d}}}dA}t||}||k}|dkrddlm}ddlm}||ddddBd |sZtd6|fd7||fd tkst trt tnd dtkst |rt |ndt |t |dtks,t |r6t |ndd8} d9d:| i} t t | d}}}dC}t||}||k}|dkrddlm}ddlm}||ddddDd |sptd6|fd7||fd tkst trt tnd dtks t |rt |ndt |t |dtksBt |rLt |ndd8} d9d:| i} t t | d}}}dE}t||}||k}|dkrddlm}ddlm}||ddddFd |std6|fd7||fd tkst trt tnd dtks"t |r,t |ndt |t |dtksXt |rbt |ndd8} d9d:| i} t t | d}}}dS)GNuFrank Zappa 🎸u…r)r)rz5asserting the value None, please use "assert is None"z9/home/ihabunek/projects/ihabunek/toot/tests/test_utils.py)r r r )r)z9%(py5)s {%(py5)s = %(py0)s(%(py1)s, %(py3)s) } == %(py8)srr)rrrrrzassert %(py10)sruF…uFr…uFra… uFran…!uFrank…"#u Frank Z…$ u Frank Za…%r u Frank Zap…&ru Frank Zapp…'ruFrank Zappa…(r)r,)r )z9%(py5)s {%(py5)s = %(py0)s(%(py1)s, %(py3)s) } is %(py7)s)rrrrrzassert %(py9)srr-r.r!/r0r 1r"2) rr&rr'rr(r)r*r+r,r-r.r/) rr0r1r3r6rrr4r5r7r8r9r9r: test_truncs  x                                                                rYc Csd}d}t||}d}||k}|dkrRddlm}ddlm}||dddd d |std |fd ||fd tkst trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}d}t||}d}||k}|dkrHddlm}ddlm}||ddddd |std |fd ||fd tks|t trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}d}t||}d}||k}|dkrHddlm}ddlm}||ddddd |std |fd ||fd tks|t trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}d}t||}d}||k}|dkrHddlm}ddlm}||ddddd |std |fd ||fd tks|t trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}d}t||}d}||k}|dkrHddlm}ddlm}||ddddd |std |fd ||fd tks|t trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}d}t||}d}||k}|dkrHddlm}ddlm}||dddd d |std |fd ||fd tks|t trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}d!}t||}d}||k}|dkrHddlm}ddlm}||dddd"d |std |fd ||fd tks|t trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}d#}t||}d$}||k}|dkrHddlm}ddlm}||dddd%d |std |fd ||fd tks|t trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}d&}t||}d'}||k}|dkrHddlm}ddlm}||dddd(d |std |fd ||fd tks|t trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}d)}t||}d*}||k}|dk rHddlm}ddlm}||dddd+d | std |fd ||fd tk s|t t rt tnd dtk st | rt |ndt |t |t |d}dd|i}t t |d}}}}d,}t||}d-}||k}|dk rHddlm}ddlm}||dddd.d | std |fd ||fd tk s|t t rt tnd dtk st | rt |ndt |t |t |d}dd|i}t t |d}}}}d/}t||}d0}||k}|dk rHddlm}ddlm}||dddd1d | std |fd ||fd tk s|t t rt tnd dtk st | rt |ndt |t |t |d}dd|i}t t |d}}}}d2}t||}d0}||k}|dk rHddlm}ddlm}||dddd3d | std |fd ||fd tk s|t t rt tnd dtk st | rt |ndt |t |t |d}dd|i}t t |d}}}}d4}t||}d}||k}|dk rHddlm}ddlm}||dddd5d | std |fd ||fd tk s|t t rt tnd dtk st | rt |ndt |t |t |d}dd|i}t t |d}}}}d6}t||}d7}||k}|dkrHddlm}ddlm}||dddd8d |std |fd ||fd tks|t trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}d9}t||}d:}||k}|dkrHddlm}ddlm}||dddd;d |std |fd ||fd tks|t trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}d<}t||}d=}||k}|dkrHddlm}ddlm}||dddd>d |std |fd ||fd tks|t trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}d?}t||}d@}||k}|dkrHddlm}ddlm}||ddddAd |std |fd ||fd tks|t trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}dB}t||}dC}||k}|dkrHddlm}ddlm}||ddddDd |std |fd ||fd tks|t trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}dE}t||}dF}||k}|dkrHddlm}ddlm}||ddddGd |std |fd ||fd tks|t trt tnd dtkst |rt |ndt |t |t |d}dd|i}t t |d}}}}dS)HNuFrank Zappa 🎸r<u…r)r)rz5asserting the value None, please use "assert is None"z9/home/ihabunek/projects/ihabunek/toot/tests/test_utils.py8)r r r )r)z9%(py5)s {%(py5)s = %(py0)s(%(py1)s, %(py3)s) } == %(py8)srr)rrrrrzassert %(py10)srr>uF…9r@uFr…:rBuFra…;rDuFran…<rFuFrank…=rH>rJu Frank Z…?rLu Frank Za…@r u Frank Zap…Aru Frank Zapp…BruFrank Zappa…CrDrEruFrank Zappa 🎸 FruFrank Zappa 🎸 Gr!uFrank Zappa 🎸 HruFrank Zappa 🎸 Ir uFrank Zappa 🎸 Jr"uFrank Zappa 🎸 K) rr&rr'rr(r)r*r+r,r-r.r/) rr0r1r3r6rrr4r5r9r9r: test_fit_text5s   x                                                          rnc Cs>d}d}t||}t|}dddddg}||k}|dkrddd lm}dd lm}||d dd d d|s&td|fd||fdt kst trt tnddt kst trt tnddt kst |rt |ndt |t |t |t |d}dd|i} t t | d}}}}}dS)NzEius voluptas eos praesentium et tempore. Quaerat nihil voluptatem excepturi reiciendis sapiente voluptate natus. Tenetur occaecati velit dicta dolores. Illo reiciendis nulla ea. Facilis nostrum non qui inventore sit.rXz1Eius voluptas eos praesentium et tempore. Quaeratz.nihil voluptatem excepturi reiciendis sapientez.voluptate natus. Tenetur occaecati velit dictaz2dolores. Illo reiciendis nulla ea. Facilis nostrumznon qui inventore sit.r)r)rz5asserting the value None, please use "assert is None"z9/home/ihabunek/projects/ihabunek/toot/tests/test_utils.pyV)r r r )r)zX%(py8)s {%(py8)s = %(py0)s(%(py6)s {%(py6)s = %(py1)s(%(py2)s, %(py4)s) }) } == %(py11)slistrlorem)rrpy2py4rrpy11zassert %(py13)spy13)rrpr&rr'rr(r)r*r+r,r-r.r/) rq @py_assert3r2r3 @py_assert10 @py_assert9rr @py_format12 @py_format14r9r9r:test_wc_wrap_plain_textNs   r{c Cs>d}d}t||}t|}dddddg}||k}|dkrddd lm}dd lm}||d dd d d|s&td|fd||fdt kst trt tnddt kst trt tnddt kst |rt |ndt |t |t |t |d}dd|i} t t | d}}}}}dS)NuEius voluptas eos praesentium et tempore. Quaerat nihil voluptatem excepturi reiciendis sapiente voluptate natus. Tenetur occaecati velit dicta dolores. Illo reiciendis nulla ea. Facilis nostrum non qui inventore  sit.rXz1Eius voluptas eos praesentium et tempore. Quaeratz.nihil voluptatem excepturi reiciendis sapientez.voluptate natus. Tenetur occaecati velit dictaz2dolores. Illo reiciendis nulla ea. Facilis nostrumznon qui inventore sit.r)r)rz5asserting the value None, please use "assert is None"z9/home/ihabunek/projects/ihabunek/toot/tests/test_utils.pyh)r r r )r)zX%(py8)s {%(py8)s = %(py0)s(%(py6)s {%(py6)s = %(py1)s(%(py2)s, %(py4)s) }) } == %(py11)srprrq)rrrrrsrrrtzassert %(py13)sru)rrpr&rr'rr(r)r*r+r,r-r.r/) rqrvr2r3rwrxrrryrzr9r9r:.test_wc_wrap_plain_text_wrap_on_any_whitespace`s   r}c Cs:d}d}t||}t|}dddg}||k}|dkr`ddlm}ddlm}||d dd d d |s"td |fd||fdt kst trt tnddt kst trt tnddt kst |rt |ndt |t |t |t |d}dd|i} t t | d}}}}}dS)Nu☕☕☕☕☕ voluptas eos praesentium et 🎸🎸🎸🎸🎸. Quaerat nihil voluptatem excepturi reiciendis sapiente voluptate natus.rXuA☕☕☕☕☕ voluptas eos praesentium et 🎸🎸🎸🎸🎸.z-Quaerat nihil voluptatem excepturi reiciendiszsapiente voluptate natus.r)r)rz5asserting the value None, please use "assert is None"z9/home/ihabunek/projects/ihabunek/toot/tests/test_utils.pyx)r r r )r)zX%(py8)s {%(py8)s = %(py0)s(%(py6)s {%(py6)s = %(py1)s(%(py2)s, %(py4)s) }) } == %(py11)srprrq)rrrrrsrrrtzassert %(py13)sru)rrpr&rr'rr(r)r*r+r,r-r.r/) rqrvr2r3rwrxrrryrzr9r9r:!test_wc_wrap_text_with_wide_charsrs    rc Cs:d}d}t||}t|}dddg}||k}|dkr`ddlm}ddlm}||d dd d d |s"td |fd||fdt kst trt tnddt kst trt tnddt kst |rt |ndt |t |t |t |d}dd|i} t t | d}}}}}dS)Nu|☕☕☕☕☕voluptaseospraesentiumet🎸🎸🎸🎸🎸.Quaeratnihilvoluptatemexcepturireiciendissapientevoluptatenatus.rXuA☕☕☕☕☕voluptaseospraesentiumet🎸🎸🎸🎸🎸.QuaerZ2atnihilvoluptatemexcepturireiciendissapientevoluptz atenatus.r)r)rz5asserting the value None, please use "assert is None"z9/home/ihabunek/projects/ihabunek/toot/tests/test_utils.py)r r r )r)zX%(py8)s {%(py8)s = %(py0)s(%(py6)s {%(py6)s = %(py1)s(%(py2)s, %(py4)s) }) } == %(py11)srprrq)rrrrrsrrrtzassert %(py13)sru)rrpr&rr'rr(r)r*r+r,r-r.r/) rqrvr2r3rwrxrrryrzr9r9r:test_wc_wrap_hard_wraps    rc Cs>d}d}t||}t|}dddddg}||k}|dkrddd lm}dd lm}||d dd d d|s&td|fd||fdt kst trt tnddt kst trt tnddt kst |rt |ndt |t |t |t |d}dd|i} t t | d}}}}}dS)Nz Eius voluptas eos praesentium et tempore. Quaerat nihil voluptatem excepturi reiciendis sapiente voluptate natus. Tenetur occaecati velit dicta dolores. Illo reiciendis nulla ea. Facilis nostrum non qui inventore sit.rXz1Eius voluptas eos praesentium et tempore. Quaeratz.nihil voluptatem excepturi reiciendis sapientez.voluptate natus. Tenetur occaecati velit dictaz2dolores. Illo reiciendis nulla ea. Facilis nostrumznon qui inventore sit.r)r)rz5asserting the value None, please use "assert is None"z9/home/ihabunek/projects/ihabunek/toot/tests/test_utils.py)r r r )r)zX%(py8)s {%(py8)s = %(py0)s(%(py6)s {%(py6)s = %(py1)s(%(py2)s, %(py4)s) }) } == %(py11)srprrq)rrrrrsrrrtzassert %(py13)sru)rrpr&rr'rr(r)r*r+r,r-r.r/) rqrvr2r3rwrxrrryrzr9r9r:test_wc_wrap_indenteds   r)builtinsr*_pytest.assertion.rewrite assertionrewriter( toot.wcstringrrrrr;rYrnr{r}rrrr9r9r9r:stoot-0.25.2/tests/__pycache__/test_version.cpython-37-pytest-5.1.2.pyc0000600000175000017500000000271113612307261026671 0ustar ihabunekihabunek00000000000000B re\@s:ddlZddlmmZddlZddlmZddZ dS)N)get_distributionc Cs tj}d}t|}|j}||k}|dkrTddlm}ddlm}||ddddd |st d |fd ||fdt kst trt tndt |d t kst trt tnd t |t |t |d }dd|i}tt|d}}}}}dS)zYVersion specified in __version__ should be the same as the one specified in setup.py.tootNr)PytestAssertRewriteWarning) warn_explicitz5asserting the value None, please use "assert is None"z;/home/ihabunek/projects/ihabunek/toot/tests/test_version.py)categoryfilenamelineno)==)zp%(py2)s {%(py2)s = %(py0)s.__version__ } == %(py10)s {%(py10)s = %(py8)s {%(py8)s = %(py4)s(%(py6)s) }.version }r)py0py2py4py6py8py10zassert %(py12)spy12)r __version__rversion_pytest.warning_typesrwarningsr @pytest_ar_call_reprcompare @py_builtinslocals_should_repr_global_name _safereprAssertionError_format_explanation) @py_assert1 @py_assert5 @py_assert7 @py_assert9 @py_assert3rr @py_format11 @py_format13r%;/home/ihabunek/projects/ihabunek/toot/tests/test_version.py test_versions  r') builtinsr_pytest.assertion.rewrite assertionrewriterr pkg_resourcesrr'r%r%r%r&s toot-0.25.2/tests/__pycache__/utils.cpython-37.pyc0000644000175000017500000000176613612307261023337 0ustar ihabunekihabunek00000000000000B te\@sdZGdddZddZdS)z Helpers for testing. c@s,eZdZiddfddZddZddZd S) MockResponseTFcCs||_||_||_||_dS)N) response_datacontentok is_redirect)selfrrrr4/home/ihabunek/projects/ihabunek/toot/tests/utils.py__init__szMockResponse.__init__cCsdS)Nr)rrrrraise_for_status szMockResponse.raise_for_statuscCs|jS)N)r)rrrrjsonszMockResponse.jsonN)__name__ __module__ __qualname__r r r rrrrrsrcs fddS)NcsS)Nr)argskwargs)valrrzretval..r)rr)rrretvalsrN)__doc__rrrrrrstoot-0.25.2/tests/test_api.py0000644000175000017500000000370313431315564017517 0ustar ihabunekihabunek00000000000000# -*- coding: utf-8 -*- import pytest from unittest import mock from toot import App, CLIENT_NAME, CLIENT_WEBSITE from toot.api import create_app, login, SCOPES, AuthenticationError from tests.utils import MockResponse @mock.patch('toot.http.anon_post') def test_create_app(mock_post): mock_post.return_value = MockResponse({ 'client_id': 'foo', 'client_secret': 'bar', }) create_app('bigfish.software') mock_post.assert_called_once_with('https://bigfish.software/api/v1/apps', { 'website': CLIENT_WEBSITE, 'client_name': CLIENT_NAME, 'scopes': SCOPES, 'redirect_uris': 'urn:ietf:wg:oauth:2.0:oob', }) @mock.patch('toot.http.anon_post') def test_login(mock_post): app = App('bigfish.software', 'https://bigfish.software', 'foo', 'bar') data = { 'grant_type': 'password', 'client_id': app.client_id, 'client_secret': app.client_secret, 'username': 'user', 'password': 'pass', 'scope': SCOPES, } mock_post.return_value = MockResponse({ 'token_type': 'bearer', 'scope': 'read write follow', 'access_token': 'xxx', 'created_at': 1492523699 }) login(app, 'user', 'pass') mock_post.assert_called_once_with( 'https://bigfish.software/oauth/token', data, allow_redirects=False) @mock.patch('toot.http.anon_post') def test_login_failed(mock_post): app = App('bigfish.software', 'https://bigfish.software', 'foo', 'bar') data = { 'grant_type': 'password', 'client_id': app.client_id, 'client_secret': app.client_secret, 'username': 'user', 'password': 'pass', 'scope': SCOPES, } mock_post.return_value = MockResponse(is_redirect=True) with pytest.raises(AuthenticationError): login(app, 'user', 'pass') mock_post.assert_called_once_with( 'https://bigfish.software/oauth/token', data, allow_redirects=False) toot-0.25.2/tests/test_auth.py0000644000175000017500000000350213431315557017706 0ustar ihabunekihabunek00000000000000# -*- coding: utf-8 -*- from toot import App, User, api, config, auth from tests.utils import retval def test_register_app(monkeypatch): app_data = {'id': 100, 'client_id': 'cid', 'client_secret': 'cs'} def assert_app(app): assert isinstance(app, App) assert app.instance == "foo.bar" assert app.base_url == "https://foo.bar" assert app.client_id == "cid" assert app.client_secret == "cs" monkeypatch.setattr(api, 'create_app', retval(app_data)) monkeypatch.setattr(api, 'get_instance', retval({"title": "foo", "version": "1"})) monkeypatch.setattr(config, 'save_app', assert_app) app = auth.register_app("foo.bar") assert_app(app) def test_create_app_from_config(monkeypatch): """When there is saved config, it's returned""" monkeypatch.setattr(config, 'load_app', retval("loaded app")) app = auth.create_app_interactive("bezdomni.net") assert app == 'loaded app' def test_create_app_registered(monkeypatch): """When there is no saved config, a new app is registered""" monkeypatch.setattr(config, 'load_app', retval(None)) monkeypatch.setattr(auth, 'register_app', retval("registered app")) app = auth.create_app_interactive("bezdomni.net") assert app == 'registered app' def test_create_user(monkeypatch): app = App(4, 5, 6, 7) def assert_user(user, activate=True): assert activate assert isinstance(user, User) assert user.instance == app.instance assert user.username == "foo" assert user.access_token == "abc" monkeypatch.setattr(config, 'save_user', assert_user) monkeypatch.setattr(api, 'verify_credentials', lambda x, y: {"username": "foo"}) user = auth.create_user(app, 'abc') assert_user(user) # # TODO: figure out how to mock input so the rest can be tested # toot-0.25.2/tests/test_config.py0000644000175000017500000001147313431315564020216 0ustar ihabunekihabunek00000000000000import os import pytest from toot import User, App, config @pytest.fixture def sample_config(): return { 'apps': { 'foo.social': { 'base_url': 'https://foo.social', 'client_id': 'abc', 'client_secret': 'def', 'instance': 'foo.social' }, 'bar.social': { 'base_url': 'https://bar.social', 'client_id': 'ghi', 'client_secret': 'jkl', 'instance': 'bar.social' }, }, 'users': { 'foo@bar.social': { 'access_token': 'mno', 'instance': 'bar.social', 'username': 'ihabunek' } }, 'active_user': 'foo@bar.social', } def test_extract_active_user_app(sample_config): user, app = config.extract_user_app(sample_config, sample_config['active_user']) assert isinstance(user, User) assert user.instance == 'bar.social' assert user.username == 'ihabunek' assert user.access_token == 'mno' assert isinstance(app, App) assert app.instance == 'bar.social' assert app.base_url == 'https://bar.social' assert app.client_id == 'ghi' assert app.client_secret == 'jkl' def test_extract_active_when_no_active_user(sample_config): # When there is no active user assert config.extract_user_app(sample_config, None) == (None, None) # When active user does not exist for whatever reason assert config.extract_user_app(sample_config, 'does-not-exist') == (None, None) # When active app does not exist for whatever reason sample_config['users']['foo@bar.social']['instance'] = 'does-not-exist' assert config.extract_user_app(sample_config, 'foo@bar.social') == (None, None) def test_save_app(sample_config): app = App('xxx.yyy', 2, 3, 4) app2 = App('moo.foo', 5, 6, 7) app_count = len(sample_config['apps']) assert 'xxx.yyy' not in sample_config['apps'] assert 'moo.foo' not in sample_config['apps'] # Sets config.save_app.__wrapped__(sample_config, app) assert len(sample_config['apps']) == app_count + 1 assert 'xxx.yyy' in sample_config['apps'] assert sample_config['apps']['xxx.yyy']['instance'] == 'xxx.yyy' assert sample_config['apps']['xxx.yyy']['base_url'] == 2 assert sample_config['apps']['xxx.yyy']['client_id'] == 3 assert sample_config['apps']['xxx.yyy']['client_secret'] == 4 # Overwrites config.save_app.__wrapped__(sample_config, app2) assert len(sample_config['apps']) == app_count + 2 assert 'xxx.yyy' in sample_config['apps'] assert 'moo.foo' in sample_config['apps'] assert sample_config['apps']['xxx.yyy']['instance'] == 'xxx.yyy' assert sample_config['apps']['xxx.yyy']['base_url'] == 2 assert sample_config['apps']['xxx.yyy']['client_id'] == 3 assert sample_config['apps']['xxx.yyy']['client_secret'] == 4 assert sample_config['apps']['moo.foo']['instance'] == 'moo.foo' assert sample_config['apps']['moo.foo']['base_url'] == 5 assert sample_config['apps']['moo.foo']['client_id'] == 6 assert sample_config['apps']['moo.foo']['client_secret'] == 7 # Idempotent config.save_app.__wrapped__(sample_config, app2) assert len(sample_config['apps']) == app_count + 2 assert 'xxx.yyy' in sample_config['apps'] assert 'moo.foo' in sample_config['apps'] assert sample_config['apps']['xxx.yyy']['instance'] == 'xxx.yyy' assert sample_config['apps']['xxx.yyy']['base_url'] == 2 assert sample_config['apps']['xxx.yyy']['client_id'] == 3 assert sample_config['apps']['xxx.yyy']['client_secret'] == 4 assert sample_config['apps']['moo.foo']['instance'] == 'moo.foo' assert sample_config['apps']['moo.foo']['base_url'] == 5 assert sample_config['apps']['moo.foo']['client_id'] == 6 assert sample_config['apps']['moo.foo']['client_secret'] == 7 def test_delete_app(sample_config): app = App('foo.social', 2, 3, 4) app_count = len(sample_config['apps']) assert 'foo.social' in sample_config['apps'] config.delete_app.__wrapped__(sample_config, app) assert 'foo.social' not in sample_config['apps'] assert len(sample_config['apps']) == app_count - 1 # Idempotent config.delete_app.__wrapped__(sample_config, app) assert 'foo.social' not in sample_config['apps'] assert len(sample_config['apps']) == app_count - 1 def test_get_config_file_path(): fn = config.get_config_file_path os.unsetenv('XDG_CONFIG_HOME') os.environ.pop('XDG_CONFIG_HOME', None) assert fn() == os.path.expanduser('~/.config/toot/config.json') os.environ['XDG_CONFIG_HOME'] = '/foo/bar/config' assert fn() == '/foo/bar/config/toot/config.json' os.environ['XDG_CONFIG_HOME'] = '~/foo/config' assert fn() == os.path.expanduser('~/foo/config/toot/config.json') toot-0.25.2/tests/test_console.py0000644000175000017500000004705013611533337020413 0ustar ihabunekihabunek00000000000000# -*- coding: utf-8 -*- import io import pytest import re from collections import namedtuple from unittest import mock from toot import console, User, App, http from toot.exceptions import ConsoleError from tests.utils import MockResponse app = App('habunek.com', 'https://habunek.com', 'foo', 'bar') user = User('habunek.com', 'ivan@habunek.com', 'xxx') MockUuid = namedtuple("MockUuid", ["hex"]) def uncolorize(text): """Remove ANSI color sequences from a string""" return re.sub(r'\x1b[^m]*m', '', text) def test_print_usage(capsys): console.print_usage() out, err = capsys.readouterr() assert "toot - a Mastodon CLI client" in out @mock.patch('uuid.uuid4') @mock.patch('toot.http.post') def test_post_defaults(mock_post, mock_uuid, capsys): mock_uuid.return_value = MockUuid("rock-on") mock_post.return_value = MockResponse({ 'url': 'https://habunek.com/@ihabunek/1234567890' }) console.run_command(app, user, 'post', ['Hello world']) mock_post.assert_called_once_with(app, user, '/api/v1/statuses', { 'status': 'Hello world', 'visibility': 'public', 'media_ids[]': None, 'sensitive': "false", 'spoiler_text': None, 'in_reply_to_id': None, 'language': None, }, headers={"Idempotency-Key": "rock-on"}) out, err = capsys.readouterr() assert 'Toot posted' in out assert 'https://habunek.com/@ihabunek/1234567890' in out assert not err @mock.patch('uuid.uuid4') @mock.patch('toot.http.post') def test_post_with_options(mock_post, mock_uuid, capsys): mock_uuid.return_value = MockUuid("up-the-irons") args = [ 'Hello world', '--visibility', 'unlisted', '--sensitive', '--spoiler-text', 'Spoiler!', '--reply-to', '123', '--language', 'hrv', ] mock_post.return_value = MockResponse({ 'url': 'https://habunek.com/@ihabunek/1234567890' }) console.run_command(app, user, 'post', args) mock_post.assert_called_once_with(app, user, '/api/v1/statuses', { 'status': 'Hello world', 'media_ids[]': None, 'visibility': 'unlisted', 'sensitive': "true", 'spoiler_text': "Spoiler!", 'in_reply_to_id': 123, 'language': 'hrv', }, headers={"Idempotency-Key": "up-the-irons"}) out, err = capsys.readouterr() assert 'Toot posted' in out assert 'https://habunek.com/@ihabunek/1234567890' in out assert not err def test_post_invalid_visibility(capsys): args = ['Hello world', '--visibility', 'foo'] with pytest.raises(SystemExit): console.run_command(app, user, 'post', args) out, err = capsys.readouterr() assert "invalid visibility value: 'foo'" in err def test_post_invalid_media(capsys): args = ['Hello world', '--media', 'does_not_exist.jpg'] with pytest.raises(SystemExit): console.run_command(app, user, 'post', args) out, err = capsys.readouterr() assert "can't open 'does_not_exist.jpg'" in err @mock.patch('toot.http.delete') def test_delete(mock_delete, capsys): console.run_command(app, user, 'delete', ['12321']) mock_delete.assert_called_once_with(app, user, '/api/v1/statuses/12321') out, err = capsys.readouterr() assert 'Status deleted' in out assert not err @mock.patch('toot.http.get') def test_timeline(mock_get, monkeypatch, capsys): mock_get.return_value = MockResponse([{ 'id': '111111111111111111', 'account': { 'display_name': 'Frank Zappa 🎸', 'acct': 'fz' }, 'created_at': '2017-04-12T15:53:18.174Z', 'content': "

The computer can't tell you the emotional story. It can give you the exact mathematical design, but what's missing is the eyebrows.

", 'reblog': None, 'in_reply_to_id': None, 'media_attachments': [], }]) console.run_command(app, user, 'timeline', ['--once']) mock_get.assert_called_once_with(app, user, '/api/v1/timelines/home?limit=10', None) out, err = capsys.readouterr() lines = out.split("\n") assert "Frank Zappa 🎸" in lines[1] assert "@fz" in lines[1] assert "2017-04-12 15:53" in lines[1] assert ( "The computer can't tell you the emotional story. It can give you the " "exact mathematical design, but\nwhat's missing is the eyebrows." in out) assert "111111111111111111" in lines[-3] assert err == "" @mock.patch('toot.http.get') def test_timeline_with_re(mock_get, monkeypatch, capsys): mock_get.return_value = MockResponse([{ 'id': '111111111111111111', 'created_at': '2017-04-12T15:53:18.174Z', 'account': { 'display_name': 'Frank Zappa', 'acct': 'fz' }, 'reblog': { 'account': { 'display_name': 'Johnny Cash', 'acct': 'jc' }, 'content': "

The computer can't tell you the emotional story. It can give you the exact mathematical design, but what's missing is the eyebrows.

", 'media_attachments': [], }, 'in_reply_to_id': '111111111111111110', 'media_attachments': [], }]) console.run_command(app, user, 'timeline', ['--once']) mock_get.assert_called_once_with(app, user, '/api/v1/timelines/home?limit=10', None) out, err = capsys.readouterr() lines = out.split("\n") assert "Frank Zappa" in lines[1] assert "@fz" in lines[1] assert "2017-04-12 15:53" in lines[1] assert ( "The computer can't tell you the emotional story. It can give you the " "exact mathematical design, but\nwhat's missing is the eyebrows." in out) assert "111111111111111111" in lines[-3] assert "↻ Reblogged @jc" in lines[-3] assert err == "" @mock.patch('toot.http.get') def test_thread(mock_get, monkeypatch, capsys): mock_get.side_effect = [ MockResponse({ 'id': '111111111111111111', 'account': { 'display_name': 'Frank Zappa', 'acct': 'fz' }, 'created_at': '2017-04-12T15:53:18.174Z', 'content': "my response in the middle", 'reblog': None, 'in_reply_to_id': '111111111111111110', 'media_attachments': [], }), MockResponse({ 'ancestors': [{ 'id': '111111111111111110', 'account': { 'display_name': 'Frank Zappa', 'acct': 'fz' }, 'created_at': '2017-04-12T15:53:18.174Z', 'content': "original content", 'media_attachments': [], 'reblog': None, 'in_reply_to_id': None}], 'descendants': [{ 'id': '111111111111111112', 'account': { 'display_name': 'Frank Zappa', 'acct': 'fz' }, 'created_at': '2017-04-12T15:53:18.174Z', 'content': "response message", 'media_attachments': [], 'reblog': None, 'in_reply_to_id': '111111111111111111'}], }), ] console.run_command(app, user, 'thread', ['111111111111111111']) calls = [ mock.call(app, user, '/api/v1/statuses/111111111111111111'), mock.call(app, user, '/api/v1/statuses/111111111111111111/context'), ] mock_get.assert_has_calls(calls, any_order=False) out, err = capsys.readouterr() assert not err # Display order assert out.index('original content') < out.index('my response in the middle') assert out.index('my response in the middle') < out.index('response message') assert "original content" in out assert "my response in the middle" in out assert "response message" in out assert "Frank Zappa" in out assert "@fz" in out assert "111111111111111111" in out assert "In reply to" in out @mock.patch('toot.http.get') def test_reblogged_by(mock_get, monkeypatch, capsys): mock_get.return_value = MockResponse([{ 'display_name': 'Terry Bozzio', 'acct': 'bozzio@drummers.social', }, { 'display_name': 'Dweezil', 'acct': 'dweezil@zappafamily.social', }]) console.run_command(app, user, 'reblogged_by', ['111111111111111111']) calls = [ mock.call(app, user, '/api/v1/statuses/111111111111111111/reblogged_by'), ] mock_get.assert_has_calls(calls, any_order=False) out, err = capsys.readouterr() # Display order expected = "\n".join([ "Terry Bozzio", " @bozzio@drummers.social", "Dweezil", " @dweezil@zappafamily.social", "", ]) assert out == expected @mock.patch('toot.http.post') def test_upload(mock_post, capsys): mock_post.return_value = MockResponse({ 'id': 123, 'url': 'https://bigfish.software/123/456', 'preview_url': 'https://bigfish.software/789/012', 'text_url': 'https://bigfish.software/345/678', 'type': 'image', }) console.run_command(app, user, 'upload', [__file__]) mock_post.call_count == 1 args, kwargs = http.post.call_args assert args == (app, user, '/api/v1/media') assert isinstance(kwargs['files']['file'], io.BufferedReader) out, err = capsys.readouterr() assert "Uploading media" in out assert __file__ in out @mock.patch('toot.http.get') def test_search(mock_get, capsys): mock_get.return_value = MockResponse({ 'hashtags': [ { 'history': [], 'name': 'foo', 'url': 'https://mastodon.social/tags/foo' }, { 'history': [], 'name': 'bar', 'url': 'https://mastodon.social/tags/bar' }, { 'history': [], 'name': 'baz', 'url': 'https://mastodon.social/tags/baz' }, ], 'accounts': [{ 'acct': 'thequeen', 'display_name': 'Freddy Mercury' }, { 'acct': 'thequeen@other.instance', 'display_name': 'Mercury Freddy' }], 'statuses': [], }) console.run_command(app, user, 'search', ['freddy']) mock_get.assert_called_once_with(app, user, '/api/v2/search', { 'q': 'freddy', 'resolve': False, }) out, err = capsys.readouterr() assert "Hashtags:\n#foo, #bar, #baz" in out assert "Accounts:" in out assert "@thequeen Freddy Mercury" in out assert "@thequeen@other.instance Mercury Freddy" in out @mock.patch('toot.http.post') @mock.patch('toot.http.get') def test_follow(mock_get, mock_post, capsys): mock_get.return_value = MockResponse([ {'id': 123, 'acct': 'blixa@other.acc'}, {'id': 321, 'acct': 'blixa'}, ]) mock_post.return_value = MockResponse() console.run_command(app, user, 'follow', ['blixa']) mock_get.assert_called_once_with(app, user, '/api/v1/accounts/search', {'q': 'blixa'}) mock_post.assert_called_once_with(app, user, '/api/v1/accounts/321/follow') out, err = capsys.readouterr() assert "You are now following blixa" in out @mock.patch('toot.http.get') def test_follow_not_found(mock_get, capsys): mock_get.return_value = MockResponse() with pytest.raises(ConsoleError) as ex: console.run_command(app, user, 'follow', ['blixa']) mock_get.assert_called_once_with(app, user, '/api/v1/accounts/search', {'q': 'blixa'}) assert "Account not found" == str(ex.value) @mock.patch('toot.http.post') @mock.patch('toot.http.get') def test_unfollow(mock_get, mock_post, capsys): mock_get.return_value = MockResponse([ {'id': 123, 'acct': 'blixa@other.acc'}, {'id': 321, 'acct': 'blixa'}, ]) mock_post.return_value = MockResponse() console.run_command(app, user, 'unfollow', ['blixa']) mock_get.assert_called_once_with(app, user, '/api/v1/accounts/search', {'q': 'blixa'}) mock_post.assert_called_once_with(app, user, '/api/v1/accounts/321/unfollow') out, err = capsys.readouterr() assert "You are no longer following blixa" in out @mock.patch('toot.http.get') def test_unfollow_not_found(mock_get, capsys): mock_get.return_value = MockResponse([]) with pytest.raises(ConsoleError) as ex: console.run_command(app, user, 'unfollow', ['blixa']) mock_get.assert_called_once_with(app, user, '/api/v1/accounts/search', {'q': 'blixa'}) assert "Account not found" == str(ex.value) @mock.patch('toot.http.get') def test_whoami(mock_get, capsys): mock_get.return_value = MockResponse({ 'acct': 'ihabunek', 'avatar': 'https://files.mastodon.social/accounts/avatars/000/046/103/original/6a1304e135cac514.jpg?1491312434', 'avatar_static': 'https://files.mastodon.social/accounts/avatars/000/046/103/original/6a1304e135cac514.jpg?1491312434', 'created_at': '2017-04-04T13:23:09.777Z', 'display_name': 'Ivan Habunek', 'followers_count': 5, 'following_count': 9, 'header': '/headers/original/missing.png', 'header_static': '/headers/original/missing.png', 'id': 46103, 'locked': False, 'note': 'A developer.', 'statuses_count': 19, 'url': 'https://mastodon.social/@ihabunek', 'username': 'ihabunek' }) console.run_command(app, user, 'whoami', []) mock_get.assert_called_once_with(app, user, '/api/v1/accounts/verify_credentials') out, err = capsys.readouterr() out = uncolorize(out) assert "@ihabunek Ivan Habunek" in out assert "A developer." in out assert "https://mastodon.social/@ihabunek" in out assert "ID: 46103" in out assert "Since: 2017-04-04 @ 13:23:09" in out assert "Followers: 5" in out assert "Following: 9" in out assert "Statuses: 19" in out @mock.patch('toot.http.get') def test_notifications(mock_get, capsys): mock_get.return_value = MockResponse([{ 'id': '1', 'type': 'follow', 'created_at': '2019-02-16T07:01:20.714Z', 'account': { 'display_name': 'Frank Zappa', 'acct': 'frank@zappa.social', }, }, { 'id': '2', 'type': 'mention', 'created_at': '2017-01-12T12:12:12.0Z', 'account': { 'display_name': 'Dweezil Zappa', 'acct': 'dweezil@zappa.social', }, 'status': { 'id': '111111111111111111', 'account': { 'display_name': 'Dweezil Zappa', 'acct': 'dweezil@zappa.social', }, 'created_at': '2017-04-12T15:53:18.174Z', 'content': "

We still have fans in 2017 @fan123

", 'reblog': None, 'in_reply_to_id': None, 'media_attachments': [], }, }, { 'id': '3', 'type': 'reblog', 'created_at': '1983-11-03T03:03:03.333Z', 'account': { 'display_name': 'Terry Bozzio', 'acct': 'terry@bozzio.social', }, 'status': { 'id': '1234', 'account': { 'display_name': 'Zappa Fan', 'acct': 'fan123@zappa-fans.social' }, 'created_at': '1983-11-04T15:53:18.174Z', 'content': "

The Black Page, a masterpiece

", 'reblog': None, 'in_reply_to_id': None, 'media_attachments': [], }, }, { 'id': '4', 'type': 'favourite', 'created_at': '1983-12-13T01:02:03.444Z', 'account': { 'display_name': 'Zappa Old Fan', 'acct': 'fan9@zappa-fans.social', }, 'status': { 'id': '1234', 'account': { 'display_name': 'Zappa Fan', 'acct': 'fan123@zappa-fans.social' }, 'created_at': '1983-11-04T15:53:18.174Z', 'content': "

The Black Page, a masterpiece

", 'reblog': None, 'in_reply_to_id': None, 'media_attachments': [], }, }]) console.run_command(app, user, 'notifications', []) mock_get.assert_called_once_with(app, user, '/api/v1/notifications') out, err = capsys.readouterr() out = uncolorize(out) width = 100 assert not err assert out == "\n".join([ "─" * width, "Frank Zappa @frank@zappa.social now follows you", "─" * width, "Dweezil Zappa @dweezil@zappa.social mentioned you in", "Dweezil Zappa @dweezil@zappa.social 2017-04-12 15:53", "", "We still have fans in 2017 @fan123", "", "ID 111111111111111111 ", "─" * width, "Terry Bozzio @terry@bozzio.social reblogged your status", "Zappa Fan @fan123@zappa-fans.social 1983-11-04 15:53", "", "The Black Page, a masterpiece", "", "ID 1234 ", "─" * width, "Zappa Old Fan @fan9@zappa-fans.social favourited your status", "Zappa Fan @fan123@zappa-fans.social 1983-11-04 15:53", "", "The Black Page, a masterpiece", "", "ID 1234 ", "─" * width, "", ]) @mock.patch('toot.http.get') def test_notifications_empty(mock_get, capsys): mock_get.return_value = MockResponse([]) console.run_command(app, user, 'notifications', []) mock_get.assert_called_once_with(app, user, '/api/v1/notifications') out, err = capsys.readouterr() out = uncolorize(out) assert not err assert out == "No notification\n" @mock.patch('toot.http.post') def test_notifications_clear(mock_post, capsys): console.run_command(app, user, 'notifications', ['--clear']) out, err = capsys.readouterr() out = uncolorize(out) mock_post.assert_called_once_with(app, user, '/api/v1/notifications/clear') assert not err assert out == 'Cleared notifications\n' def u(user_id, access_token="abc"): username, instance = user_id.split("@") return { "instance": instance, "username": username, "access_token": access_token, } @mock.patch('toot.config.save_config') @mock.patch('toot.config.load_config') def test_logout(mock_load, mock_save, capsys): mock_load.return_value = { "users": { "king@gizzard.social": u("king@gizzard.social"), "lizard@wizard.social": u("lizard@wizard.social"), }, "active_user": "king@gizzard.social", } console.run_command(app, user, "logout", ["king@gizzard.social"]) mock_save.assert_called_once_with({ 'users': { 'lizard@wizard.social': u("lizard@wizard.social") }, 'active_user': None }) out, err = capsys.readouterr() assert "✓ User king@gizzard.social logged out" in out @mock.patch('toot.config.save_config') @mock.patch('toot.config.load_config') def test_activate(mock_load, mock_save, capsys): mock_load.return_value = { "users": { "king@gizzard.social": u("king@gizzard.social"), "lizard@wizard.social": u("lizard@wizard.social"), }, "active_user": "king@gizzard.social", } console.run_command(app, user, "activate", ["lizard@wizard.social"]) mock_save.assert_called_once_with({ 'users': { "king@gizzard.social": u("king@gizzard.social"), 'lizard@wizard.social': u("lizard@wizard.social") }, 'active_user': "lizard@wizard.social" }) out, err = capsys.readouterr() assert "✓ User lizard@wizard.social active" in out toot-0.25.2/tests/test_utils.py0000644000175000017500000001340613431315573020107 0ustar ihabunekihabunek00000000000000from toot.wcstring import wc_wrap, trunc, pad, fit_text def test_pad(): # guitar symbol will occupy two cells, so padded text should be 1 # character shorter text = 'Frank Zappa 🎸' # Negative values are basically ignored assert pad(text, -100) is text # Padding to length smaller than text length does nothing assert pad(text, 11) is text assert pad(text, 12) is text assert pad(text, 13) is text assert pad(text, 14) is text assert pad(text, 15) == 'Frank Zappa 🎸 ' assert pad(text, 16) == 'Frank Zappa 🎸 ' assert pad(text, 17) == 'Frank Zappa 🎸 ' assert pad(text, 18) == 'Frank Zappa 🎸 ' assert pad(text, 19) == 'Frank Zappa 🎸 ' assert pad(text, 20) == 'Frank Zappa 🎸 ' def test_trunc(): text = 'Frank Zappa 🎸' assert trunc(text, 1) == '…' assert trunc(text, 2) == 'F…' assert trunc(text, 3) == 'Fr…' assert trunc(text, 4) == 'Fra…' assert trunc(text, 5) == 'Fran…' assert trunc(text, 6) == 'Frank…' assert trunc(text, 7) == 'Frank…' assert trunc(text, 8) == 'Frank Z…' assert trunc(text, 9) == 'Frank Za…' assert trunc(text, 10) == 'Frank Zap…' assert trunc(text, 11) == 'Frank Zapp…' assert trunc(text, 12) == 'Frank Zappa…' assert trunc(text, 13) == 'Frank Zappa…' # Truncating to length larger than text length does nothing assert trunc(text, 14) is text assert trunc(text, 15) is text assert trunc(text, 16) is text assert trunc(text, 17) is text assert trunc(text, 18) is text assert trunc(text, 19) is text assert trunc(text, 20) is text def test_fit_text(): text = 'Frank Zappa 🎸' assert fit_text(text, 1) == '…' assert fit_text(text, 2) == 'F…' assert fit_text(text, 3) == 'Fr…' assert fit_text(text, 4) == 'Fra…' assert fit_text(text, 5) == 'Fran…' assert fit_text(text, 6) == 'Frank…' assert fit_text(text, 7) == 'Frank…' assert fit_text(text, 8) == 'Frank Z…' assert fit_text(text, 9) == 'Frank Za…' assert fit_text(text, 10) == 'Frank Zap…' assert fit_text(text, 11) == 'Frank Zapp…' assert fit_text(text, 12) == 'Frank Zappa…' assert fit_text(text, 13) == 'Frank Zappa…' assert fit_text(text, 14) == 'Frank Zappa 🎸' assert fit_text(text, 15) == 'Frank Zappa 🎸 ' assert fit_text(text, 16) == 'Frank Zappa 🎸 ' assert fit_text(text, 17) == 'Frank Zappa 🎸 ' assert fit_text(text, 18) == 'Frank Zappa 🎸 ' assert fit_text(text, 19) == 'Frank Zappa 🎸 ' assert fit_text(text, 20) == 'Frank Zappa 🎸 ' def test_wc_wrap_plain_text(): lorem = ( "Eius voluptas eos praesentium et tempore. Quaerat nihil voluptatem " "excepturi reiciendis sapiente voluptate natus. Tenetur occaecati " "velit dicta dolores. Illo reiciendis nulla ea. Facilis nostrum non " "qui inventore sit." ) assert list(wc_wrap(lorem, 50)) == [ #01234567890123456789012345678901234567890123456789 # noqa "Eius voluptas eos praesentium et tempore. Quaerat", "nihil voluptatem excepturi reiciendis sapiente", "voluptate natus. Tenetur occaecati velit dicta", "dolores. Illo reiciendis nulla ea. Facilis nostrum", "non qui inventore sit.", ] def test_wc_wrap_plain_text_wrap_on_any_whitespace(): lorem = ( "Eius\t\tvoluptas\teos\tpraesentium\tet\ttempore.\tQuaerat\tnihil\tvoluptatem\t" "excepturi\nreiciendis\n\nsapiente\nvoluptate\nnatus.\nTenetur\noccaecati\n" "velit\rdicta\rdolores.\rIllo\rreiciendis\rnulla\r\r\rea.\rFacilis\rnostrum\rnon\r" "qui\u2003inventore\u2003\u2003sit." # em space ) assert list(wc_wrap(lorem, 50)) == [ #01234567890123456789012345678901234567890123456789 # noqa "Eius voluptas eos praesentium et tempore. Quaerat", "nihil voluptatem excepturi reiciendis sapiente", "voluptate natus. Tenetur occaecati velit dicta", "dolores. Illo reiciendis nulla ea. Facilis nostrum", "non qui inventore sit.", ] def test_wc_wrap_text_with_wide_chars(): lorem = ( "☕☕☕☕☕ voluptas eos praesentium et 🎸🎸🎸🎸🎸. Quaerat nihil " "voluptatem excepturi reiciendis sapiente voluptate natus." ) assert list(wc_wrap(lorem, 50)) == [ #01234567890123456789012345678901234567890123456789 # noqa "☕☕☕☕☕ voluptas eos praesentium et 🎸🎸🎸🎸🎸.", "Quaerat nihil voluptatem excepturi reiciendis", "sapiente voluptate natus.", ] def test_wc_wrap_hard_wrap(): lorem = ( "☕☕☕☕☕voluptaseospraesentiumet🎸🎸🎸🎸🎸.Quaeratnihil" "voluptatemexcepturireiciendissapientevoluptatenatus." ) assert list(wc_wrap(lorem, 50)) == [ #01234567890123456789012345678901234567890123456789 # noqa "☕☕☕☕☕voluptaseospraesentiumet🎸🎸🎸🎸🎸.Quaer", "atnihilvoluptatemexcepturireiciendissapientevolupt", "atenatus.", ] def test_wc_wrap_indented(): lorem = ( " Eius voluptas eos praesentium et tempore. Quaerat nihil voluptatem " " excepturi reiciendis sapiente voluptate natus. Tenetur occaecati " " velit dicta dolores. Illo reiciendis nulla ea. Facilis nostrum non " " qui inventore sit." ) assert list(wc_wrap(lorem, 50)) == [ #01234567890123456789012345678901234567890123456789 # noqa "Eius voluptas eos praesentium et tempore. Quaerat", "nihil voluptatem excepturi reiciendis sapiente", "voluptate natus. Tenetur occaecati velit dicta", "dolores. Illo reiciendis nulla ea. Facilis nostrum", "non qui inventore sit.", ] toot-0.25.2/tests/test_version.py0000644000175000017500000000036113431315562020426 0ustar ihabunekihabunek00000000000000import toot from pkg_resources import get_distribution def test_version(): """Version specified in __version__ should be the same as the one specified in setup.py.""" assert toot.__version__ == get_distribution('toot').version toot-0.25.2/tests/utils.py0000644000175000017500000000064413431315564017050 0ustar ihabunekihabunek00000000000000""" Helpers for testing. """ class MockResponse: def __init__(self, response_data={}, ok=True, is_redirect=False): self.response_data = response_data self.content = response_data self.ok = ok self.is_redirect = is_redirect def raise_for_status(self): pass def json(self): return self.response_data def retval(val): return lambda *args, **kwargs: val toot-0.25.2/toot/0000755000175000017500000000000013612310376015152 5ustar ihabunekihabunek00000000000000toot-0.25.2/toot/__init__.py0000644000175000017500000000056113612310164017260 0ustar ihabunekihabunek00000000000000# -*- coding: utf-8 -*- from collections import namedtuple __version__ = '0.25.2' App = namedtuple('App', ['instance', 'base_url', 'client_id', 'client_secret']) User = namedtuple('User', ['instance', 'username', 'access_token']) DEFAULT_INSTANCE = 'mastodon.social' CLIENT_NAME = 'toot - a Mastodon CLI client' CLIENT_WEBSITE = 'https://github.com/ihabunek/toot' toot-0.25.2/toot/api.py0000644000175000017500000001602013611530120016262 0ustar ihabunekihabunek00000000000000# -*- coding: utf-8 -*- import re import uuid from urllib.parse import urlparse, urlencode, quote from toot import http, CLIENT_NAME, CLIENT_WEBSITE from toot.exceptions import AuthenticationError from toot.utils import str_bool SCOPES = 'read write follow' def _account_action(app, user, account, action): url = '/api/v1/accounts/{}/{}'.format(account, action) return http.post(app, user, url).json() def _status_action(app, user, status_id, action): url = '/api/v1/statuses/{}/{}'.format(status_id, action) return http.post(app, user, url).json() def create_app(domain, scheme='https'): url = '{}://{}/api/v1/apps'.format(scheme, domain) data = { 'client_name': CLIENT_NAME, 'redirect_uris': 'urn:ietf:wg:oauth:2.0:oob', 'scopes': SCOPES, 'website': CLIENT_WEBSITE, } return http.anon_post(url, data).json() def login(app, username, password): url = app.base_url + '/oauth/token' data = { 'grant_type': 'password', 'client_id': app.client_id, 'client_secret': app.client_secret, 'username': username, 'password': password, 'scope': SCOPES, } response = http.anon_post(url, data, allow_redirects=False) # If auth fails, it redirects to the login page if response.is_redirect: raise AuthenticationError() return response.json() def get_browser_login_url(app): """Returns the URL for manual log in via browser""" return "{}/oauth/authorize/?{}".format(app.base_url, urlencode({ "response_type": "code", "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", "scope": SCOPES, "client_id": app.client_id, })) def request_access_token(app, authorization_code): url = app.base_url + '/oauth/token' data = { 'grant_type': 'authorization_code', 'client_id': app.client_id, 'client_secret': app.client_secret, 'code': authorization_code, 'redirect_uri': 'urn:ietf:wg:oauth:2.0:oob', } return http.anon_post(url, data, allow_redirects=False).json() def post_status( app, user, status, visibility='public', media_ids=None, sensitive=False, spoiler_text=None, in_reply_to_id=None, language=None, ): """ Posts a new status. https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#posting-a-new-status """ # Idempotency key assures the same status is not posted multiple times # if the request is retried. headers = {"Idempotency-Key": uuid.uuid4().hex} return http.post(app, user, '/api/v1/statuses', { 'status': status, 'media_ids[]': media_ids, 'visibility': visibility, 'sensitive': str_bool(sensitive), 'spoiler_text': spoiler_text, 'in_reply_to_id': in_reply_to_id, 'language': language, }, headers=headers).json() def delete_status(app, user, status_id): """ Deletes a status with given ID. https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#deleting-a-status """ return http.delete(app, user, '/api/v1/statuses/{}'.format(status_id)) def favourite(app, user, status_id): return _status_action(app, user, status_id, 'favourite') def unfavourite(app, user, status_id): return _status_action(app, user, status_id, 'unfavourite') def reblog(app, user, status_id): return _status_action(app, user, status_id, 'reblog') def unreblog(app, user, status_id): return _status_action(app, user, status_id, 'unreblog') def pin(app, user, status_id): return _status_action(app, user, status_id, 'pin') def unpin(app, user, status_id): return _status_action(app, user, status_id, 'unpin') def context(app, user, status_id): url = '/api/v1/statuses/{}/context'.format(status_id) return http.get(app, user, url).json() def reblogged_by(app, user, status_id): url = '/api/v1/statuses/{}/reblogged_by'.format(status_id) return http.get(app, user, url).json() def _get_next_path(headers): """Given timeline response headers, returns the path to the next batch""" links = headers.get('Link', '') matches = re.match('<([^>]+)>; rel="next"', links) if matches: parsed = urlparse(matches.group(1)) return "?".join([parsed.path, parsed.query]) def _timeline_generator(app, user, path, params=None): while path: response = http.get(app, user, path, params) yield response.json() path = _get_next_path(response.headers) def _anon_timeline_generator(instance, path, params=None): while path: url = "https://{}{}".format(instance, path) response = http.anon_get(url, params) yield response.json() path = _get_next_path(response.headers) def home_timeline_generator(app, user, limit=20): path = '/api/v1/timelines/home?limit={}'.format(limit) return _timeline_generator(app, user, path) def public_timeline_generator(instance, local=False, limit=20): path = '/api/v1/timelines/public' params = {'local': str_bool(local), 'limit': limit} return _anon_timeline_generator(instance, path, params) def tag_timeline_generator(instance, hashtag, local=False, limit=20): path = '/api/v1/timelines/tag/{}'.format(quote(hashtag)) params = {'local': str_bool(local), 'limit': limit} return _anon_timeline_generator(instance, path, params) def timeline_list_generator(app, user, list_id, limit=20): path = '/api/v1/timelines/list/{}'.format(list_id) return _timeline_generator(app, user, path, {'limit': limit}) def upload_media(app, user, file): return http.post(app, user, '/api/v1/media', files={ 'file': file }).json() def search(app, user, query, resolve): return http.get(app, user, '/api/v2/search', { 'q': query, 'resolve': resolve, }).json() def search_accounts(app, user, query): return http.get(app, user, '/api/v1/accounts/search', { 'q': query, }).json() def follow(app, user, account): return _account_action(app, user, account, 'follow') def unfollow(app, user, account): return _account_action(app, user, account, 'unfollow') def mute(app, user, account): return _account_action(app, user, account, 'mute') def unmute(app, user, account): return _account_action(app, user, account, 'unmute') def block(app, user, account): return _account_action(app, user, account, 'block') def unblock(app, user, account): return _account_action(app, user, account, 'unblock') def verify_credentials(app, user): return http.get(app, user, '/api/v1/accounts/verify_credentials').json() def single_status(app, user, status_id): url = '/api/v1/statuses/{}'.format(status_id) return http.get(app, user, url).json() def get_notifications(app, user): return http.get(app, user, '/api/v1/notifications').json() def clear_notifications(app, user): http.post(app, user, '/api/v1/notifications/clear') def get_instance(domain, scheme="https"): url = "{}://{}/api/v1/instance".format(scheme, domain) return http.anon_get(url).json() toot-0.25.2/toot/auth.py0000644000175000017500000000607113527514014016471 0ustar ihabunekihabunek00000000000000# -*- coding: utf-8 -*- import webbrowser from builtins import input from getpass import getpass from toot import api, config, DEFAULT_INSTANCE, User, App from toot.exceptions import ApiError, ConsoleError from toot.output import print_out def register_app(domain, scheme='https'): print_out("Looking up instance info...") instance = api.get_instance(domain, scheme) print_out("Found instance {} running Mastodon version {}".format( instance['title'], instance['version'])) try: print_out("Registering application...") response = api.create_app(domain, scheme) except ApiError: raise ConsoleError("Registration failed.") base_url = scheme + '://' + domain app = App(domain, base_url, response['client_id'], response['client_secret']) config.save_app(app) print_out("Application tokens saved.") return app def create_app_interactive(instance=None, scheme='https'): if not instance: print_out("Choose an instance [{}]: ".format(DEFAULT_INSTANCE), end="") instance = input() if not instance: instance = DEFAULT_INSTANCE return config.load_app(instance) or register_app(instance, scheme) def create_user(app, access_token): # Username is not yet known at this point, so fetch it from Mastodon user = User(app.instance, None, access_token) creds = api.verify_credentials(app, user) user = User(app.instance, creds['username'], access_token) config.save_user(user, activate=True) print_out("Access token saved to config at: {}".format( config.get_config_file_path())) return user def login_interactive(app, email=None): print_out("Log in to {}".format(app.instance)) if email: print_out("Email: {}".format(email)) while not email: email = input('Email: ') password = getpass('Password: ') try: print_out("Authenticating...") response = api.login(app, email, password) except ApiError: raise ConsoleError("Login failed") return create_user(app, response['access_token']) BROWSER_LOGIN_EXPLANATION = """ This authentication method requires you to log into your Mastodon instance in your browser, where you will be asked to authorize toot to access your account. When you do, you will be given an authorization code which you need to paste here. """ def login_browser_interactive(app): url = api.get_browser_login_url(app) print_out(BROWSER_LOGIN_EXPLANATION) print_out("This is the login URL:") print_out(url) print_out("") yesno = input("Open link in default browser? [Y/n]") if not yesno or yesno.lower() == 'y': webbrowser.open(url) authorization_code = "" while not authorization_code: authorization_code = input("Authorization code: ") print_out("\nRequesting access token...") response = api.request_access_token(app, authorization_code) return create_user(app, response['access_token']) toot-0.25.2/toot/commands.py0000644000175000017500000002360413611533305017330 0ustar ihabunekihabunek00000000000000# -*- coding: utf-8 -*- import sys from toot import api, config from toot.auth import login_interactive, login_browser_interactive, create_app_interactive from toot.exceptions import ConsoleError, NotFoundError from toot.output import (print_out, print_instance, print_account, print_search_results, print_timeline, print_notifications) from toot.utils import assert_domain_exists, editor_input, multiline_input, EOF_KEY def get_timeline_generator(app, user, args): # Make sure tag, list and public are not used simultaneously if len([arg for arg in [args.tag, args.list, args.public] if arg]) > 1: raise ConsoleError("Only one of --public, --tag, or --list can be used at one time.") if args.local and not (args.public or args.tag): raise ConsoleError("The --local option is only valid alongside --public or --tag.") if args.instance and not (args.public or args.tag): raise ConsoleError("The --instance option is only valid alongside --public or --tag.") if args.public: instance = args.instance or app.instance return api.public_timeline_generator(instance, local=args.local, limit=args.count) elif args.tag: instance = args.instance or app.instance return api.tag_timeline_generator(instance, args.tag, local=args.local, limit=args.count) elif args.list: return api.timeline_list_generator(app, user, args.list, limit=args.count) else: return api.home_timeline_generator(app, user, limit=args.count) def timeline(app, user, args): generator = get_timeline_generator(app, user, args) while(True): try: items = next(generator) except StopIteration: print_out("That's all folks.") return if args.reverse: items = reversed(items) print_timeline(items) if args.once: break char = input("\nContinue? [Y/n] ") if char.lower() == "n": break def thread(app, user, args): toot = api.single_status(app, user, args.status_id) context = api.context(app, user, args.status_id) thread = [] for item in context['ancestors']: thread.append(item) thread.append(toot) for item in context['descendants']: thread.append(item) print_timeline(thread) def curses(app, user, args): generator = get_timeline_generator(app, user, args) from toot.ui.app import TimelineApp TimelineApp(app, user, generator).run() def post(app, user, args): # TODO: this might be achievable, explore options if args.editor and not sys.stdin.isatty(): raise ConsoleError("Cannot run editor if not in tty.") if args.media and len(args.media) > 4: raise ConsoleError("Cannot attach more than 4 files.") # Read any text that might be piped to stdin if not args.text and not sys.stdin.isatty(): args.text = sys.stdin.read().rstrip() if args.media: media = [_do_upload(app, user, file) for file in args.media] media_ids = [m["id"] for m in media] else: media = None media_ids = None if media and not args.text: args.text = "\n".join(m['text_url'] for m in media) if args.editor: args.text = editor_input(args.editor, args.text) elif not args.text: print_out("Write or paste your toot. Press {} to post it.".format(EOF_KEY)) args.text = multiline_input() if not args.text: raise ConsoleError("You must specify either text or media to post.") response = api.post_status( app, user, args.text, visibility=args.visibility, media_ids=media_ids, sensitive=args.sensitive, spoiler_text=args.spoiler_text, in_reply_to_id=args.reply_to, language=args.language, ) print_out("Toot posted: {}".format(response.get('url'))) def delete(app, user, args): api.delete_status(app, user, args.status_id) print_out("✓ Status deleted") def favourite(app, user, args): api.favourite(app, user, args.status_id) print_out("✓ Status favourited") def unfavourite(app, user, args): api.unfavourite(app, user, args.status_id) print_out("✓ Status unfavourited") def reblog(app, user, args): api.reblog(app, user, args.status_id) print_out("✓ Status reblogged") def unreblog(app, user, args): api.unreblog(app, user, args.status_id) print_out("✓ Status unreblogged") def pin(app, user, args): api.pin(app, user, args.status_id) print_out("✓ Status pinned") def unpin(app, user, args): api.unpin(app, user, args.status_id) print_out("✓ Status unpinned") def reblogged_by(app, user, args): for account in api.reblogged_by(app, user, args.status_id): print_out("{}\n @{}".format(account['display_name'], account['acct'])) def auth(app, user, args): config_data = config.load_config() if not config_data["users"]: print_out("You are not logged in to any accounts") return active_user = config_data["active_user"] print_out("Authenticated accounts:") for uid, u in config_data["users"].items(): active_label = "ACTIVE" if active_user == uid else "" print_out("* {} {}".format(uid, active_label)) path = config.get_config_file_path() print_out("\nAuth tokens are stored in: {}".format(path)) def login_cli(app, user, args): app = create_app_interactive(instance=args.instance, scheme=args.scheme) login_interactive(app, args.email) print_out() print_out("✓ Successfully logged in.") def login(app, user, args): app = create_app_interactive(instance=args.instance, scheme=args.scheme) login_browser_interactive(app) print_out() print_out("✓ Successfully logged in.") def logout(app, user, args): user = config.load_user(args.account, throw=True) config.delete_user(user) print_out("✓ User {} logged out".format(config.user_id(user))) def activate(app, user, args): user = config.load_user(args.account, throw=True) config.activate_user(user) print_out("✓ User {} active".format(config.user_id(user))) def upload(app, user, args): response = _do_upload(app, user, args.file) msg = "Successfully uploaded media ID {}, type '{}'" print_out() print_out(msg.format(response['id'], response['type'])) print_out("Original URL: {}".format(response['url'])) print_out("Preview URL: {}".format(response['preview_url'])) print_out("Text URL: {}".format(response['text_url'])) def search(app, user, args): response = api.search(app, user, args.query, args.resolve) print_search_results(response) def _do_upload(app, user, file): print_out("Uploading media: {}".format(file.name)) return api.upload_media(app, user, file) def _find_account(app, user, account_name): """For a given account name, returns the Account object. Raises an exception if not found. """ if not account_name: raise ConsoleError("Empty account name given") accounts = api.search_accounts(app, user, account_name) if account_name[0] == "@": account_name = account_name[1:] for account in accounts: if account['acct'] == account_name: return account raise ConsoleError("Account not found") def follow(app, user, args): account = _find_account(app, user, args.account) api.follow(app, user, account['id']) print_out("✓ You are now following {}".format(args.account)) def unfollow(app, user, args): account = _find_account(app, user, args.account) api.unfollow(app, user, account['id']) print_out("✓ You are no longer following {}".format(args.account)) def mute(app, user, args): account = _find_account(app, user, args.account) api.mute(app, user, account['id']) print_out("✓ You have muted {}".format(args.account)) def unmute(app, user, args): account = _find_account(app, user, args.account) api.unmute(app, user, account['id']) print_out("✓ {} is no longer muted".format(args.account)) def block(app, user, args): account = _find_account(app, user, args.account) api.block(app, user, account['id']) print_out("✓ You are now blocking {}".format(args.account)) def unblock(app, user, args): account = _find_account(app, user, args.account) api.unblock(app, user, account['id']) print_out("✓ {} is no longer blocked".format(args.account)) def whoami(app, user, args): account = api.verify_credentials(app, user) print_account(account) def whois(app, user, args): account = _find_account(app, user, args.account) print_account(account) def instance(app, user, args): name = args.instance or (app and app.instance) if not name: raise ConsoleError("Please specify instance name.") assert_domain_exists(name) try: instance = api.get_instance(name, args.scheme) print_instance(instance) except NotFoundError: raise ConsoleError( "Instance not found at {}.\n" "The given domain probably does not host a Mastodon instance.".format(name) ) def notifications(app, user, args): if args.clear: api.clear_notifications(app, user) print_out("Cleared notifications") return notifications = api.get_notifications(app, user) if not notifications: print_out("No notification") return print_notifications(notifications) def tui(app, user, args): from .tui.app import TUI TUI.create(app, user).run() toot-0.25.2/toot/config.py0000644000175000017500000001015113535176350016775 0ustar ihabunekihabunek00000000000000import json import os import sys from functools import wraps from os.path import dirname, join, expanduser from toot import User, App from toot.exceptions import ConsoleError from toot.output import print_out TOOT_CONFIG_DIR_NAME = "toot" TOOT_CONFIG_FILE_NAME = "config.json" def get_config_dir(): """Returns the path to toot config directory""" # On Windows, store the config in roaming appdata if sys.platform == "win32" and "APPDATA" in os.environ: return join(os.getenv("APPDATA"), TOOT_CONFIG_DIR_NAME) # Respect XDG_CONFIG_HOME env variable if set # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html if "XDG_CONFIG_HOME" in os.environ: config_home = expanduser(os.environ["XDG_CONFIG_HOME"]) return join(config_home, TOOT_CONFIG_DIR_NAME) # Default to ~/.config/toot/ return join(expanduser("~"), ".config", TOOT_CONFIG_DIR_NAME) def get_config_file_path(): """Returns the path to toot config file.""" return join(get_config_dir(), TOOT_CONFIG_FILE_NAME) CONFIG_FILE = get_config_file_path() def user_id(user): return "{}@{}".format(user.username, user.instance) def make_config(path): """Creates an empty toot configuration file.""" config = { "apps": {}, "users": {}, "active_user": None, } print_out("Creating config file at {}".format(path)) # Ensure dir exists os.makedirs(dirname(path), exist_ok=True) # Create file with 600 permissions since it contains secrets fd = os.open(path, os.O_CREAT | os.O_WRONLY, 0o600) with os.fdopen(fd, 'w') as f: json.dump(config, f, indent=True) def load_config(): if not os.path.exists(CONFIG_FILE): make_config(CONFIG_FILE) with open(CONFIG_FILE) as f: return json.load(f) def save_config(config): with open(CONFIG_FILE, 'w') as f: return json.dump(config, f, indent=True) def extract_user_app(config, user_id): if user_id not in config['users']: return None, None user_data = config['users'][user_id] instance = user_data['instance'] if instance not in config['apps']: return None, None app_data = config['apps'][instance] return User(**user_data), App(**app_data) def get_active_user_app(): """Returns (User, App) of active user or (None, None) if no user is active.""" config = load_config() if config['active_user']: return extract_user_app(config, config['active_user']) return None, None def get_user_app(user_id): """Returns (User, App) for given user ID or (None, None) if user is not logged in.""" return extract_user_app(load_config(), user_id) def load_app(instance): config = load_config() if instance in config['apps']: return App(**config['apps'][instance]) def load_user(user_id, throw=False): config = load_config() if user_id in config['users']: return User(**config['users'][user_id]) if throw: raise ConsoleError("User '{}' not found".format(user_id)) def modify_config(f): @wraps(f) def wrapper(*args, **kwargs): config = load_config() config = f(config, *args, **kwargs) save_config(config) return config return wrapper @modify_config def save_app(config, app): assert isinstance(app, App) config['apps'][app.instance] = app._asdict() return config @modify_config def delete_app(config, app): assert isinstance(app, App) config['apps'].pop(app.instance, None) return config @modify_config def save_user(config, user, activate=True): assert isinstance(user, User) config['users'][user_id(user)] = user._asdict() if activate: config['active_user'] = user_id(user) return config @modify_config def delete_user(config, user): assert isinstance(user, User) config['users'].pop(user_id(user), None) if config['active_user'] == user_id(user): config['active_user'] = None return config @modify_config def activate_user(config, user): assert isinstance(user, User) config['active_user'] = user_id(user) return config toot-0.25.2/toot/console.py0000644000175000017500000003500213604371002017160 0ustar ihabunekihabunek00000000000000# -*- coding: utf-8 -*- import logging import os import shutil import sys from argparse import ArgumentParser, FileType, ArgumentTypeError from collections import namedtuple from toot import config, commands, CLIENT_NAME, CLIENT_WEBSITE, __version__ from toot.exceptions import ApiError, ConsoleError from toot.output import print_out, print_err VISIBILITY_CHOICES = ['public', 'unlisted', 'private', 'direct'] def language(value): """Validates the language parameter""" if len(value) != 3: raise ArgumentTypeError( "Invalid language specified: '{}'. Expected a 3 letter " "abbreviation according to ISO 639-2 standard.".format(value) ) return value def visibility(value): """Validates the visibility parameter""" if value not in VISIBILITY_CHOICES: raise ValueError("Invalid visibility value") return value def timeline_count(value): n = int(value) if not 0 < n <= 20: raise ArgumentTypeError("Number of toots should be between 1 and 20.") return n def editor(value): if not value: raise ArgumentTypeError( "Editor not specified in --editor option and $EDITOR environment " "variable not set." ) # Check editor executable exists exe = shutil.which(value) if not exe: raise ArgumentTypeError("Editor `{}` not found".format(value)) return exe Command = namedtuple("Command", ["name", "description", "require_auth", "arguments"]) # Arguments added to every command common_args = [ (["--no-color"], { "help": "don't use ANSI colors in output", "action": 'store_true', "default": False, }), (["--quiet"], { "help": "don't write to stdout on success", "action": 'store_true', "default": False, }), (["--debug"], { "help": "show debug log in console", "action": 'store_true', "default": False, }), ] # Arguments added to commands which require authentication common_auth_args = [ (["-u", "--using"], { "help": "the account to use, overrides active account", }), ] account_arg = (["account"], { "help": "account name, e.g. 'Gargron@mastodon.social'", }) instance_arg = (["-i", "--instance"], { "type": str, "help": 'mastodon instance to log into e.g. "mastodon.social"', }) email_arg = (["-e", "--email"], { "type": str, "help": 'email address to log in with', }) scheme_arg = (["--disable-https"], { "help": "disable HTTPS and use insecure HTTP", "dest": "scheme", "default": "https", "action": "store_const", "const": "http", }) status_id_arg = (["status_id"], { "help": "ID of the status", "type": int, }) # Arguments for selecting a timeline (see `toot.commands.get_timeline_generator`) common_timeline_args = [ (["-p", "--public"], { "action": "store_true", "default": False, "help": "show public timeline (does not require auth)", }), (["-t", "--tag"], { "type": str, "help": "show hashtag timeline (does not require auth)", }), (["-l", "--local"], { "action": "store_true", "default": False, "help": "show only statuses from local instance (public and tag timelines only)", }), (["-i", "--instance"], { "type": str, "help": "mastodon instance from which to read (public and tag timelines only)", }), (["--list"], { "type": int, "help": "show timeline for given list.", }), ] timeline_args = common_timeline_args + [ (["-c", "--count"], { "type": timeline_count, "help": "number of toots to show per page (1-20, default 10).", "default": 10, }), (["-r", "--reverse"], { "action": "store_true", "default": False, "help": "Reverse the order of the shown timeline (to new posts at the bottom)", }), (["-1", "--once"], { "action": "store_true", "default": False, "help": "Only show the first toots, do not prompt to continue.", }), ] curses_args = common_timeline_args + [ (["-c", "--count"], { "type": timeline_count, "help": "number of toots to show per page (1-20, default 20).", "default": 20, }), ] AUTH_COMMANDS = [ Command( name="login", description="Log into a mastodon instance using your browser (recommended)", arguments=[instance_arg, scheme_arg], require_auth=False, ), Command( name="login_cli", description="Log in from the console, does NOT support two factor authentication", arguments=[instance_arg, email_arg, scheme_arg], require_auth=False, ), Command( name="activate", description="Switch between logged in accounts.", arguments=[account_arg], require_auth=False, ), Command( name="logout", description="Log out, delete stored access keys", arguments=[account_arg], require_auth=False, ), Command( name="auth", description="Show logged in accounts and instances", arguments=[], require_auth=False, ), ] TUI_COMMANDS = [ Command( name="tui", description="Launches the toot terminal user interface", arguments=[], require_auth=True, ), Command( name="curses", description="An experimental timeline app (DEPRECATED, use 'toot tui' instead)", arguments=curses_args, require_auth=False, ), ] READ_COMMANDS = [ Command( name="whoami", description="Display logged in user details", arguments=[], require_auth=True, ), Command( name="whois", description="Display account details", arguments=[ (["account"], { "help": "account name or numeric ID" }), ], require_auth=True, ), Command( name="notifications", description="Notifications for logged in user", arguments=[ (["--clear"], { "help": "delete all notifications from the server", "action": 'store_true', "default": False, }), ], require_auth=True, ), Command( name="instance", description="Display instance details", arguments=[ (["instance"], { "help": "instance domain (e.g. 'mastodon.social') or blank to use current", "nargs": "?", }), scheme_arg, ], require_auth=False, ), Command( name="search", description="Search for users or hashtags", arguments=[ (["query"], { "help": "the search query", }), (["-r", "--resolve"], { "action": 'store_true', "default": False, "help": "Resolve non-local accounts", }), ], require_auth=True, ), Command( name="thread", description="Show toot thread items", arguments=[ (["status_id"], { "help": "Show thread for toot.", }), ], require_auth=True, ), Command( name="timeline", description="Show recent items in a timeline (home by default)", arguments=timeline_args, require_auth=True, ), ] POST_COMMANDS = [ Command( name="post", description="Post a status text to your timeline", arguments=[ (["text"], { "help": "The status text to post.", "nargs": "?", }), (["-m", "--media"], { "action": "append", "type": FileType("rb"), "help": "path to the media file to attach (specify multiple " "times to attach up to 4 files)" }), (["-v", "--visibility"], { "type": visibility, "default": "public", "help": 'post visibility, one of: %s' % ", ".join(VISIBILITY_CHOICES), }), (["-s", "--sensitive"], { "action": 'store_true', "default": False, "help": "mark the media as NSFW", }), (["-p", "--spoiler-text"], { "type": str, "help": "text to be shown as a warning before the actual content", }), (["-r", "--reply-to"], { "type": int, "help": "local ID of the status you want to reply to", }), (["-l", "--language"], { "type": language, "help": "ISO 639-2 language code of the toot, to skip automatic detection", }), (["-e", "--editor"], { "type": editor, "nargs": "?", "const": os.getenv("EDITOR", ""), # option given without value "help": "Specify an editor to compose your toot, " "defaults to editor defined in $EDITOR env variable.", }), ], require_auth=True, ), Command( name="upload", description="Upload an image or video file", arguments=[ (["file"], { "help": "Path to the file to upload", "type": FileType('rb') }) ], require_auth=True, ), ] STATUS_COMMANDS = [ Command( name="delete", description="Delete a status", arguments=[status_id_arg], require_auth=True, ), Command( name="favourite", description="Favourite a status", arguments=[status_id_arg], require_auth=True, ), Command( name="unfavourite", description="Unfavourite a status", arguments=[status_id_arg], require_auth=True, ), Command( name="reblog", description="Reblog a status", arguments=[status_id_arg], require_auth=True, ), Command( name="unreblog", description="Unreblog a status", arguments=[status_id_arg], require_auth=True, ), Command( name="reblogged_by", description="Show accounts that reblogged the status", arguments=[status_id_arg], require_auth=False, ), Command( name="pin", description="Pin a status", arguments=[status_id_arg], require_auth=True, ), Command( name="unpin", description="Unpin a status", arguments=[status_id_arg], require_auth=True, ), ] ACCOUNTS_COMMANDS = [ Command( name="follow", description="Follow an account", arguments=[ account_arg, ], require_auth=True, ), Command( name="unfollow", description="Unfollow an account", arguments=[ account_arg, ], require_auth=True, ), Command( name="mute", description="Mute an account", arguments=[ account_arg, ], require_auth=True, ), Command( name="unmute", description="Unmute an account", arguments=[ account_arg, ], require_auth=True, ), Command( name="block", description="Block an account", arguments=[ account_arg, ], require_auth=True, ), Command( name="unblock", description="Unblock an account", arguments=[ account_arg, ], require_auth=True, ), ] COMMANDS = AUTH_COMMANDS + READ_COMMANDS + TUI_COMMANDS + POST_COMMANDS + STATUS_COMMANDS + ACCOUNTS_COMMANDS def print_usage(): max_name_len = max(len(command.name) for command in COMMANDS) groups = [ ("Authentication", AUTH_COMMANDS), ("TUI", TUI_COMMANDS), ("Read", READ_COMMANDS), ("Post", POST_COMMANDS), ("Status", STATUS_COMMANDS), ("Accounts", ACCOUNTS_COMMANDS), ] print_out("{}".format(CLIENT_NAME)) print_out("v{}".format(__version__)) for name, cmds in groups: print_out("") print_out(name + ":") for cmd in cmds: cmd_name = cmd.name.ljust(max_name_len + 2) print_out(" toot {} {}".format(cmd_name, cmd.description)) print_out("") print_out("To get help for each command run:") print_out(" toot --help") print_out("") print_out("{}".format(CLIENT_WEBSITE)) def get_argument_parser(name, command): parser = ArgumentParser( prog='toot %s' % name, description=command.description, epilog=CLIENT_WEBSITE) combined_args = command.arguments + common_args if command.require_auth: combined_args += common_auth_args for args, kwargs in combined_args: parser.add_argument(*args, **kwargs) return parser def run_command(app, user, name, args): command = next((c for c in COMMANDS if c.name == name), None) if not command: print_err("Unknown command '{}'\n".format(name)) print_usage() return parser = get_argument_parser(name, command) parsed_args = parser.parse_args(args) # Override the active account if 'using' option is given if command.require_auth and parsed_args.using: user, app = config.get_user_app(parsed_args.using) if not user or not app: raise ConsoleError("User '{}' not found".format(parsed_args.using)) if command.require_auth and (not user or not app): print_err("This command requires that you are logged in.") print_err("Please run `toot login` first.") return fn = commands.__dict__.get(name) if not fn: raise NotImplementedError("Command '{}' does not have an implementation.".format(name)) return fn(app, user, parsed_args) def main(): # Enable debug logging if --debug is in args if "--debug" in sys.argv: filename = os.getenv("TOOT_LOG_FILE") logging.basicConfig(level=logging.DEBUG, filename=filename) command_name = sys.argv[1] if len(sys.argv) > 1 else None args = sys.argv[2:] if not command_name: return print_usage() user, app = config.get_active_user_app() try: run_command(app, user, command_name, args) except (ConsoleError, ApiError) as e: print_err(str(e)) sys.exit(1) except KeyboardInterrupt: pass toot-0.25.2/toot/exceptions.py0000644000175000017500000000054613221703175017711 0ustar ihabunekihabunek00000000000000class ApiError(Exception): """Raised when an API request fails for whatever reason.""" class NotFoundError(ApiError): """Raised when an API requests returns a 404.""" class AuthenticationError(ApiError): """Raised when login fails.""" class ConsoleError(Exception): """Raised when an error occurs which needs to be show to the user.""" toot-0.25.2/toot/http.py0000644000175000017500000000477413527514014016517 0ustar ihabunekihabunek00000000000000from requests import Request, Session from toot import __version__ from toot.exceptions import NotFoundError, ApiError from toot.logging import log_request, log_response def send_request(request, allow_redirects=True): # Set a user agent string # Required for accesing instances using Cloudfront DDOS protection. request.headers["User-Agent"] = "toot/{}".format(__version__) log_request(request) with Session() as session: prepared = session.prepare_request(request) settings = session.merge_environment_settings(prepared.url, {}, None, None, None) response = session.send(prepared, allow_redirects=allow_redirects, **settings) log_response(response) return response def _get_error_message(response): """Attempt to extract an error message from response body""" try: data = response.json() if "error_description" in data: return data['error_description'] if "error" in data: return data['error'] except Exception: pass return "Unknown error" def process_response(response): if not response.ok: error = _get_error_message(response) if response.status_code == 404: raise NotFoundError(error) raise ApiError(error) return response def get(app, user, url, params=None): url = app.base_url + url headers = {"Authorization": "Bearer " + user.access_token} request = Request('GET', url, headers, params=params) response = send_request(request) return process_response(response) def anon_get(url, params=None): request = Request('GET', url, None, params=params) response = send_request(request) return process_response(response) def post(app, user, url, data=None, files=None, allow_redirects=True, headers={}): url = app.base_url + url headers["Authorization"] = "Bearer " + user.access_token request = Request('POST', url, headers, files, data) response = send_request(request, allow_redirects) return process_response(response) def delete(app, user, url, data=None): url = app.base_url + url headers = {"Authorization": "Bearer " + user.access_token} request = Request('DELETE', url, headers=headers, data=data) response = send_request(request) return process_response(response) def anon_post(url, data=None, files=None, allow_redirects=True): request = Request('POST', url, {}, files, data) response = send_request(request, allow_redirects) return process_response(response) toot-0.25.2/toot/logging.py0000644000175000017500000000171413604623122017152 0ustar ihabunekihabunek00000000000000from logging import getLogger logger = getLogger('toot') def log_request(request): logger.debug(">>> \033[32m{} {}\033[0m".format(request.method, request.url)) if request.headers: logger.debug(">>> HEADERS: \033[33m{}\033[0m".format(request.headers)) if request.data: logger.debug(">>> DATA: \033[33m{}\033[0m".format(request.data)) if request.files: logger.debug(">>> FILES: \033[33m{}\033[0m".format(request.files)) if request.params: logger.debug(">>> PARAMS: \033[33m{}\033[0m".format(request.params)) def log_response(response): if response.ok: logger.debug("<<< \033[32m{}\033[0m".format(response)) logger.debug("<<< \033[33m{}\033[0m".format(response.content)) else: logger.debug("<<< \033[31m{}\033[0m".format(response)) logger.debug("<<< \033[31m{}\033[0m".format(response.content)) def log_debug(*msgs): logger.debug(" ".join(str(m) for m in msgs)) toot-0.25.2/toot/output.py0000644000175000017500000001426313541752244017077 0ustar ihabunekihabunek00000000000000# -*- coding: utf-8 -*- import os import re import sys from datetime import datetime from textwrap import wrap from wcwidth import wcswidth from toot.utils import format_content, get_text, parse_html from toot.wcstring import wc_wrap START_CODES = { 'red': '\033[31m', 'green': '\033[32m', 'yellow': '\033[33m', 'blue': '\033[34m', 'magenta': '\033[35m', 'cyan': '\033[36m', } END_CODE = '\033[0m' START_PATTERN = "<(" + "|".join(START_CODES.keys()) + ")>" END_PATTERN = "" def start_code(match): name = match.group(1) return START_CODES[name] def colorize(text): text = re.sub(START_PATTERN, start_code, text) text = re.sub(END_PATTERN, END_CODE, text) return text def strip_tags(text): text = re.sub(START_PATTERN, '', text) text = re.sub(END_PATTERN, '', text) return text def use_ansi_color(): """Returns True if ANSI color codes should be used.""" # Windows doesn't support color unless ansicon is installed # See: http://adoxa.altervista.org/ansicon/ if sys.platform == 'win32' and 'ANSICON' not in os.environ: return False # Don't show color if stdout is not a tty, e.g. if output is piped on if not sys.stdout.isatty(): return False # Don't show color if explicitly specified in options if "--no-color" in sys.argv: return False return True USE_ANSI_COLOR = use_ansi_color() QUIET = "--quiet" in sys.argv def print_out(*args, **kwargs): if not QUIET: args = [colorize(a) if USE_ANSI_COLOR else strip_tags(a) for a in args] print(*args, **kwargs) def print_err(*args, **kwargs): args = ["{}".format(a) for a in args] args = [colorize(a) if USE_ANSI_COLOR else strip_tags(a) for a in args] print(*args, file=sys.stderr, **kwargs) def print_instance(instance): print_out("{}".format(instance['title'])) print_out("{}".format(instance['uri'])) print_out("running Mastodon {}".format(instance['version'])) print_out("") description = instance['description'].strip() if not description: return lines = [line.strip() for line in format_content(description) if line.strip()] for line in lines: for l in wrap(line.strip()): print_out(l) print_out() def print_account(account): print_out("@{} {}".format(account['acct'], account['display_name'])) note = get_text(account['note']) if note: print_out("") print_out("\n".join(wrap(note))) print_out("") print_out("ID: {}".format(account['id'])) print_out("Since: {}".format(account['created_at'][:19].replace('T', ' @ '))) print_out("") print_out("Followers: {}".format(account['followers_count'])) print_out("Following: {}".format(account['following_count'])) print_out("Statuses: {}".format(account['statuses_count'])) print_out("") print_out(account['url']) HASHTAG_PATTERN = re.compile(r'(?\\1', line) def print_search_results(results): accounts = results['accounts'] hashtags = results['hashtags'] if accounts: print_out("\nAccounts:") for account in accounts: print_out("* @{} {}".format( account['acct'], account['display_name'] )) if hashtags: print_out("\nHashtags:") print_out(", ".join(["#{}".format(t["name"]) for t in hashtags])) if not accounts and not hashtags: print_out("Nothing found") def print_status(status, width): reblog = status['reblog'] content = reblog['content'] if reblog else status['content'] media_attachments = reblog['media_attachments'] if reblog else status['media_attachments'] in_reply_to = status['in_reply_to_id'] time = status['created_at'] time = datetime.strptime(time, "%Y-%m-%dT%H:%M:%S.%fZ") time = time.strftime('%Y-%m-%d %H:%M%z') username = "@" + status['account']['acct'] spacing = width - wcswidth(username) - wcswidth(time) display_name = status['account']['display_name'] if display_name: spacing -= wcswidth(display_name) + 1 print_out("{}{}{}{}".format( "{} ".format(display_name) if display_name else "", "{}".format(username), " " * spacing, "{}".format(time), )) for paragraph in parse_html(content): print_out("") for line in paragraph: for subline in wc_wrap(line, width): print_out(highlight_hashtags(subline)) if media_attachments: print_out("\nMedia:") for attachment in media_attachments: url = attachment['text_url'] or attachment['url'] for line in wc_wrap(url, width): print_out(line) print_out("\n{}{}{}".format( "ID {} ".format(status['id']), "↲ In reply to {} ".format(in_reply_to) if in_reply_to else "", "↻ Reblogged @{} ".format(reblog['account']['acct']) if reblog else "", )) def print_timeline(items, width=100): print_out("─" * width) for item in items: print_status(item, width) print_out("─" * width) notification_msgs = { "follow": "{account} now follows you", "mention": "{account} mentioned you in", "reblog": "{account} reblogged your status", "favourite": "{account} favourited your status", } def print_notification(notification, width=100): account = "{display_name} @{acct}".format(**notification["account"]) msg = notification_msgs.get(notification["type"]) if msg is None: return print_out("─" * width) print_out(msg.format(account=account)) status = notification.get("status") if status is not None: print_status(status, width) def print_notifications(notifications, width=100): for notification in notifications: print_notification(notification) print_out("─" * width) toot-0.25.2/toot/tui/0000755000175000017500000000000013612310376015753 5ustar ihabunekihabunek00000000000000toot-0.25.2/toot/tui/__init__.py0000644000175000017500000000044713533471273020077 0ustar ihabunekihabunek00000000000000from urwid.command_map import command_map from urwid.command_map import CURSOR_UP, CURSOR_DOWN, CURSOR_LEFT, CURSOR_RIGHT # Add movement using h/j/k/l to default command map command_map._command.update({ 'k': CURSOR_UP, 'j': CURSOR_DOWN, 'h': CURSOR_LEFT, 'l': CURSOR_RIGHT, }) toot-0.25.2/toot/tui/app.py0000644000175000017500000004120213612307154017104 0ustar ihabunekihabunek00000000000000import logging import urwid from concurrent.futures import ThreadPoolExecutor from toot import api, __version__ from .compose import StatusComposer from .constants import PALETTE from .entities import Status from .overlays import ExceptionStackTrace, GotoMenu, Help, StatusSource from .overlays import StatusDeleteConfirmation from .timeline import Timeline from .utils import show_media logger = logging.getLogger(__name__) urwid.set_encoding('UTF-8') class Header(urwid.WidgetWrap): def __init__(self, app, user): self.app = app self.user = user self.text = urwid.Text("") self.cols = urwid.Columns([ ("pack", urwid.Text(('header_bold', 'toot'))), ("pack", urwid.Text(('header', ' | {}@{}'.format(user.username, app.instance)))), ("pack", self.text), ]) widget = urwid.AttrMap(self.cols, 'header') widget = urwid.Padding(widget) self._wrapped_widget = widget def clear_text(self, text): self.text.set_text("") def set_text(self, text): self.text.set_text(" | " + text) class Footer(urwid.Pile): def __init__(self): self.status = urwid.Text("") self.message = urwid.Text("") return super().__init__([ urwid.AttrMap(self.status, "footer_status"), urwid.AttrMap(self.message, "footer_message"), ]) def set_status(self, text): self.status.set_text(text) def clear_status(self, text): self.status.set_text("") def set_message(self, text): self.message.set_text(text) def set_error_message(self, text): self.message.set_text(("footer_message_error", text)) def clear_message(self): self.message.set_text("") class TUI(urwid.Frame): """Main TUI frame.""" @classmethod def create(cls, app, user): """Factory method, sets up TUI and an event loop.""" tui = cls(app, user) loop = urwid.MainLoop( tui, palette=PALETTE, event_loop=urwid.AsyncioEventLoop(), unhandled_input=tui.unhandled_input, ) tui.loop = loop return tui def __init__(self, app, user): self.app = app self.user = user self.loop = None # set in `create` self.executor = ThreadPoolExecutor(max_workers=1) self.timeline_generator = api.home_timeline_generator(app, user, limit=40) # Show intro screen while toots are being loaded self.body = self.build_intro() self.header = Header(app, user) self.footer = Footer() self.footer.set_status("Loading...") # Default max status length, updated on startup self.max_toot_chars = 500 self.timeline = None self.overlay = None self.exception = None super().__init__(self.body, header=self.header, footer=self.footer) def run(self): self.loop.set_alarm_in(0, lambda *args: self.async_load_instance()) self.loop.set_alarm_in(0, lambda *args: self.async_load_timeline( is_initial=True, timeline_name="home")) self.loop.run() self.executor.shutdown(wait=False) def build_intro(self): font = urwid.font.Thin6x6Font() # NB: Padding with width="clip" will convert the fixed BigText widget # to a flow widget so it can be used in a Pile. big_text = "Toot {}".format(__version__) big_text = urwid.BigText(("intro_bigtext", big_text), font) big_text = urwid.Padding(big_text, align="center", width="clip") intro = urwid.Pile([ big_text, urwid.Divider(), urwid.Text([ "Maintained by ", ("intro_smalltext", "@ihabunek"), " and contributors" ], align="center"), urwid.Divider(), urwid.Text(("intro_smalltext", "Loading toots..."), align="center"), ]) return urwid.Filler(intro) def run_in_thread(self, fn, args=[], kwargs={}, done_callback=None, error_callback=None): """Runs `fn(*args, **kwargs)` asynchronously in a separate thread. On completion calls `done_callback` if `fn` exited cleanly, or `error_callback` if an exception was caught. Callback methods are invoked in the main thread, not the thread in which `fn` is executed. """ def _default_error_callback(ex): self.exception = ex self.footer.set_error_message("An exeption occured, press E to view") _error_callback = error_callback or _default_error_callback def _done(future): try: result = future.result() if done_callback: # Use alarm to invoke callback in main thread self.loop.set_alarm_in(0, lambda *args: done_callback(result)) except Exception as ex: exception = ex logger.exception(exception) self.loop.set_alarm_in(0, lambda *args: _error_callback(exception)) future = self.executor.submit(fn, *args, **kwargs) future.add_done_callback(_done) return future def connect_default_timeline_signals(self, timeline): def _compose(*args): self.show_compose() def _delete(timeline, status): if status.is_mine: self.show_delete_confirmation(status) def _reply(timeline, status): self.show_compose(status) def _source(timeline, status): self.show_status_source(status) def _media(timeline, status): self.show_media(status) def _menu(timeline, status): self.show_context_menu(status) urwid.connect_signal(timeline, "compose", _compose) urwid.connect_signal(timeline, "delete", _delete) urwid.connect_signal(timeline, "favourite", self.async_toggle_favourite) urwid.connect_signal(timeline, "focus", self.refresh_footer) urwid.connect_signal(timeline, "media", _media) urwid.connect_signal(timeline, "menu", _menu) urwid.connect_signal(timeline, "reblog", self.async_toggle_reblog) urwid.connect_signal(timeline, "reply", _reply) urwid.connect_signal(timeline, "source", _source) def build_timeline(self, name, statuses): def _close(*args): raise urwid.ExitMainLoop() def _next(*args): self.async_load_timeline(is_initial=False) def _thread(timeline, status): self.show_thread(status) timeline = Timeline(name, statuses) self.connect_default_timeline_signals(timeline) urwid.connect_signal(timeline, "next", _next) urwid.connect_signal(timeline, "close", _close) urwid.connect_signal(timeline, "thread", _thread) return timeline def make_status(self, status_data): is_mine = self.user.username == status_data["account"]["acct"] return Status(status_data, is_mine, self.app.instance) def show_thread(self, status): def _close(*args): """When thread is closed, go back to the main timeline.""" self.body = self.timeline self.body.refresh_status_details() self.refresh_footer(self.timeline) # This is pretty fast, so it's probably ok to block while context is # loaded, can be made async later if needed context = api.context(self.app, self.user, status.original.id) ancestors = [self.make_status(s) for s in context["ancestors"]] descendants = [self.make_status(s) for s in context["descendants"]] statuses = ancestors + [status] + descendants focus = len(ancestors) timeline = Timeline("thread", statuses, focus, is_thread=True) self.connect_default_timeline_signals(timeline) urwid.connect_signal(timeline, "close", _close) self.body = timeline self.refresh_footer(timeline) def async_load_timeline(self, is_initial, timeline_name=None): """Asynchronously load a list of statuses.""" def _load_statuses(): self.footer.set_message("Loading statuses...") try: data = next(self.timeline_generator) except StopIteration: return [] finally: self.footer.clear_message() return [self.make_status(s) for s in data] def _done_initial(statuses): """Process initial batch of statuses, construct a Timeline.""" self.timeline = self.build_timeline(timeline_name, statuses) self.timeline.refresh_status_details() # Draw first status self.refresh_footer(self.timeline) self.body = self.timeline def _done_next(statuses): """Process sequential batch of statuses, adds statuses to the existing timeline.""" self.timeline.append_statuses(statuses) return self.run_in_thread(_load_statuses, done_callback=_done_initial if is_initial else _done_next) def async_load_instance(self): """ Attempt to update max_toot_chars from instance data. Does not work on vanilla Mastodon, works on Pleroma. See: https://github.com/tootsuite/mastodon/issues/4915 """ def _load_instance(): return api.get_instance(self.app.instance) def _done(instance): if "max_toot_chars" in instance: self.max_toot_chars return self.run_in_thread(_load_instance, done_callback=_done) def refresh_footer(self, timeline): """Show status details in footer.""" status, index, count = timeline.get_focused_status_with_counts() self.footer.set_status([ ("footer_status_bold", "[{}] ".format(timeline.name)), status.id, " - status ", str(index + 1), " of ", str(count), ]) def show_status_source(self, status): self.open_overlay( widget=StatusSource(status), title="Status source", ) def show_exception(self, exception): self.open_overlay( widget=ExceptionStackTrace(exception), title="Unhandled Exception", ) def show_compose(self, in_reply_to=None): def _close(*args): self.close_overlay() def _post(timeline, *args): self.post_status(*args) composer = StatusComposer(self.max_toot_chars, in_reply_to) urwid.connect_signal(composer, "close", _close) urwid.connect_signal(composer, "post", _post) self.open_overlay(composer, title="Compose status") def show_goto_menu(self): menu = GotoMenu() urwid.connect_signal(menu, "home_timeline", lambda x: self.goto_home_timeline()) urwid.connect_signal(menu, "public_timeline", lambda x, local: self.goto_public_timeline(local)) urwid.connect_signal(menu, "hashtag_timeline", lambda x, tag, local: self.goto_tag_timeline(tag, local=local)) self.open_overlay(menu, title="Go to", options=dict( align="center", width=("relative", 60), valign="middle", height=9, )) def show_help(self): self.open_overlay(Help(), title="Help") def goto_home_timeline(self): self.timeline_generator = api.home_timeline_generator( self.app, self.user, limit=40) promise = self.async_load_timeline(is_initial=True, timeline_name="home") promise.add_done_callback(lambda *args: self.close_overlay()) def goto_public_timeline(self, local): self.timeline_generator = api.public_timeline_generator( self.app.instance, local=local, limit=40) promise = self.async_load_timeline(is_initial=True, timeline_name="public") promise.add_done_callback(lambda *args: self.close_overlay()) def goto_tag_timeline(self, tag, local): self.timeline_generator = api.tag_timeline_generator( self.app.instance, tag, local=local, limit=40) promise = self.async_load_timeline(is_initial=True, timeline_name="#{}".format(tag)) promise.add_done_callback(lambda *args: self.close_overlay()) def show_media(self, status): urls = [m["url"] for m in status.original.data["media_attachments"]] if urls: show_media(urls) def show_context_menu(self, status): # TODO: show context menu pass def show_delete_confirmation(self, status): def _delete(widget): promise = self.async_delete_status(self.timeline, status) promise.add_done_callback(lambda *args: self.close_overlay()) def _close(widget): self.close_overlay() widget = StatusDeleteConfirmation(status) urwid.connect_signal(widget, "close", _close) urwid.connect_signal(widget, "delete", _delete) self.open_overlay(widget, title="Delete status?", options=dict( align="center", width=("relative", 60), valign="middle", height=5, )) def post_status(self, content, warning, visibility, in_reply_to_id): data = api.post_status(self.app, self.user, content, spoiler_text=warning, visibility=visibility, in_reply_to_id=in_reply_to_id) status = self.make_status(data) # TODO: instead of this, fetch new items from the timeline? self.timeline.prepend_status(status) self.timeline.focus_status(status) self.footer.set_message("Status posted {} \\o/".format(status.id)) self.close_overlay() def async_toggle_favourite(self, timeline, status): def _favourite(): logger.info("Favouriting {}".format(status)) api.favourite(self.app, self.user, status.id) def _unfavourite(): logger.info("Unfavouriting {}".format(status)) api.unfavourite(self.app, self.user, status.id) def _done(loop): # Create a new Status with flipped favourited flag new_data = status.data new_data["favourited"] = not status.favourited new_status = self.make_status(new_data) timeline.update_status(new_status) self.run_in_thread( _unfavourite if status.favourited else _favourite, done_callback=_done ) def async_toggle_reblog(self, timeline, status): def _reblog(): logger.info("Reblogging {}".format(status)) api.reblog(self.app, self.user, status.id) def _unreblog(): logger.info("Unreblogging {}".format(status)) api.unreblog(self.app, self.user, status.id) def _done(loop): # Create a new Status with flipped reblogged flag new_data = status.data new_data["reblogged"] = not status.reblogged new_status = self.make_status(new_data) timeline.update_status(new_status) self.run_in_thread( _unreblog if status.reblogged else _reblog, done_callback=_done ) def async_delete_status(self, timeline, status): def _delete(): api.delete_status(self.app, self.user, status.id) def _done(loop): timeline.remove_status(status) return self.run_in_thread(_delete, done_callback=_done) # --- Overlay handling ----------------------------------------------------- default_overlay_options = dict( align="center", width=("relative", 80), valign="middle", height=("relative", 80), ) def open_overlay(self, widget, options={}, title=""): top_widget = urwid.LineBox(widget, title=title) bottom_widget = self.body _options = self.default_overlay_options.copy() _options.update(options) self.overlay = urwid.Overlay( top_widget, bottom_widget, **_options ) self.body = self.overlay def close_overlay(self): self.body = self.overlay.bottom_w self.overlay = None # --- Keys ----------------------------------------------------------------- def unhandled_input(self, key): # TODO: this should not be in unhandled input if key in ('e', 'E'): if self.exception: self.show_exception(self.exception) elif key in ('g', 'G'): if not self.overlay: self.show_goto_menu() elif key in ('h', 'H'): if not self.overlay: self.show_help() elif key == 'esc': if self.overlay: self.close_overlay() elif key in ('q', 'Q'): if self.overlay: self.close_overlay() else: raise urwid.ExitMainLoop() toot-0.25.2/toot/tui/compose.py0000644000175000017500000001164013604371002017766 0ustar ihabunekihabunek00000000000000import urwid import logging from .constants import VISIBILITY_OPTIONS from .widgets import Button, EditBox logger = logging.getLogger(__name__) class StatusComposer(urwid.Frame): """ UI for compose and posting a status message. """ signals = ["close", "post"] def __init__(self, max_chars, in_reply_to=None): self.in_reply_to = in_reply_to self.max_chars = max_chars text = self.get_initial_text(in_reply_to) self.content_edit = EditBox( edit_text=text, edit_pos=len(text), multiline=True, allow_tab=True) urwid.connect_signal(self.content_edit.edit, "change", self.text_changed) self.char_count = urwid.Text(["0/{}".format(max_chars)]) self.cw_edit = None self.cw_add_button = Button("Add content warning", on_press=self.add_content_warning) self.cw_remove_button = Button("Remove content warning", on_press=self.remove_content_warning) self.visibility = "public" self.visibility_button = Button("Visibility: {}".format(self.visibility), on_press=self.choose_visibility) self.post_button = Button("Post", on_press=self.post) self.cancel_button = Button("Cancel", on_press=self.close) contents = list(self.generate_list_items()) self.walker = urwid.SimpleListWalker(contents) self.listbox = urwid.ListBox(self.walker) return super().__init__(self.listbox) def get_initial_text(self, in_reply_to): if not in_reply_to: return "" text = '@{} '.format(in_reply_to.account) mentions = ['@{}'.format(m["acct"]) for m in in_reply_to.mentions] if mentions: text += '\n\n{}'.format(' '.join(mentions)) return text def text_changed(self, edit, text): count = self.max_chars - len(text) text = "{}/{}".format(count, self.max_chars) color = "warning" if count < 0 else "" self.char_count.set_text((color, text)) def generate_list_items(self): if self.in_reply_to: yield urwid.Text(("gray", "Replying to {}".format(self.in_reply_to.account))) yield urwid.AttrWrap(urwid.Divider("-"), "gray") yield urwid.Text("Status message") yield self.content_edit yield self.char_count yield urwid.Divider() if self.cw_edit: yield urwid.Text("Content warning") yield self.cw_edit yield urwid.Divider() yield self.cw_remove_button else: yield self.cw_add_button yield self.visibility_button yield self.post_button yield self.cancel_button def refresh(self): self.walker = urwid.SimpleListWalker(list(self.generate_list_items())) self.listbox.body = self.walker def choose_visibility(self, *args): list_items = [urwid.Text("Choose status visibility:")] for visibility, caption, description in VISIBILITY_OPTIONS: text = "{} - {}".format(caption, description) button = Button(text, on_press=self.set_visibility, user_data=visibility) list_items.append(button) self.walker = urwid.SimpleListWalker(list_items) self.listbox.body = self.walker # Initially focus currently chosen visibility focus_map = {v[0]: n + 1 for n, v in enumerate(VISIBILITY_OPTIONS)} focus = focus_map.get(self.visibility, 1) self.walker.set_focus(focus) def set_visibility(self, widget, visibility): self.visibility = visibility self.visibility_button.set_label("Visibility: {}".format(self.visibility)) self.refresh() self.walker.set_focus(7 if self.cw_edit else 4) def add_content_warning(self, button): self.cw_edit = EditBox(multiline=True, allow_tab=True) self.refresh() self.walker.set_focus(4) def remove_content_warning(self, button): self.cw_edit = None self.refresh() self.walker.set_focus(3) def set_error_message(self, msg): self.footer = urwid.Text(("footer_message_error", msg)) def clear_error_message(self): self.footer = None def post(self, button): self.clear_error_message() # Don't lstrip content to avoid removing intentional leading whitespace # However, do strip both sides to check if there is any content there content = self.content_edit.edit_text.rstrip() content = None if not content.strip() else content warning = self.cw_edit.edit_text.rstrip() if self.cw_edit else "" warning = None if not warning.strip() else warning if not content: self.set_error_message("Cannot post an empty message") return in_reply_to_id = self.in_reply_to.id if self.in_reply_to else None self._emit("post", content, warning, self.visibility, in_reply_to_id) def close(self, button): self._emit("close") toot-0.25.2/toot/tui/constants.py0000644000175000017500000000305213604371002020333 0ustar ihabunekihabunek00000000000000# name, fg, bg, mono, fg_h, bg_h PALETTE = [ # Components ('button', 'white', 'black'), ('button_focused', 'light gray', 'dark magenta'), ('columns_divider', 'white', 'dark blue'), ('content_warning', 'white', 'dark magenta'), ('editbox', 'white', 'black'), ('editbox_focused', 'white', 'dark magenta'), ('footer_message', 'dark green', ''), ('footer_message_error', 'light red', ''), ('footer_status', 'white', 'dark blue'), ('footer_status_bold', 'white, bold', 'dark blue'), ('header', 'white', 'dark blue'), ('header_bold', 'white,bold', 'dark blue'), ('intro_bigtext', 'yellow', ''), ('intro_smalltext', 'light blue', ''), ('poll_bar', 'white', 'dark blue'), # Functional ('hashtag', 'light cyan,bold', ''), ('link', ',italics', ''), ('link_focused', ',italics', 'dark magenta'), # Colors ('bold', ',bold', ''), ('blue', 'light blue', ''), ('blue_bold', 'light blue, bold', ''), ('blue_selected', 'white', 'dark blue'), ('cyan', 'dark cyan', ''), ('cyan_bold', 'dark cyan,bold', ''), ('gray', 'dark gray', ''), ('green', 'dark green', ''), ('green_selected', 'white,bold', 'dark green'), ('yellow', 'yellow', ''), ('yellow_bold', 'yellow,bold', ''), ('warning', 'light red', ''), ] VISIBILITY_OPTIONS = [ ("public", "Public", "Post to public timelines"), ("private", "Private", "Do not post to public timelines"), ("unlisted", "Unlisted", "Post to followers only"), ("direct", "Direct", "Post to mentioned users only"), ] toot-0.25.2/toot/tui/entities.py0000644000175000017500000000517313612307410020152 0ustar ihabunekihabunek00000000000000from collections import namedtuple from .utils import parse_datetime Author = namedtuple("Author", ["account", "display_name", "username"]) class Status: """ A wrapper around the Status entity data fetched from Mastodon. https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#status Attributes ---------- reblog : Status or None The reblogged status if it exists. original : Status If a reblog, the reblogged status, otherwise self. """ def __init__(self, data, is_mine, default_instance): """ Parameters ---------- data : dict Status data as received from Mastodon. https://docs.joinmastodon.org/api/entities/#status is_mine : bool Whether the status was created by the logged in user. default_instance : str The domain of the instance into which the user is logged in. Used to create fully qualified account names for users on the same instance. Mastodon only populates the name, not the domain. """ self.data = data self.is_mine = is_mine self.default_instance = default_instance # This can be toggled by the user self.show_sensitive = False # TODO: clean up self.id = self.data["id"] self.account = self._get_account() self.created_at = parse_datetime(data["created_at"]) self.author = self._get_author() self.favourited = data.get("favourited", False) self.reblogged = data.get("reblogged", False) self.in_reply_to = data.get("in_reply_to_id") self.url = data.get("url") self.mentions = data.get("mentions") self.reblog = self._get_reblog() @property def original(self): return self.reblog or self def _get_reblog(self): reblog = self.data.get("reblog") if not reblog: return None reblog_is_mine = self.is_mine and ( self.data["account"]["acct"] == reblog["account"]["acct"] ) return Status(reblog, reblog_is_mine, self.default_instance) def _get_author(self): acct = self.data['account']['acct'] acct = acct if "@" in acct else "{}@{}".format(acct, self.default_instance) return Author(acct, self.data['account']['display_name'], self.data['account']['username']) def _get_account(self): acct = self.data['account']['acct'] return acct if "@" in acct else "{}@{}".format(acct, self.default_instance) def __repr__(self): return "".format(self.id, self.account) toot-0.25.2/toot/tui/overlays.py0000644000175000017500000001222513604370777020207 0ustar ihabunekihabunek00000000000000import json import traceback import urwid import webbrowser from toot import __version__ from .utils import highlight_keys from .widgets import Button, EditBox, SelectableText class StatusSource(urwid.ListBox): """Shows status data, as returned by the server, as formatted JSON.""" def __init__(self, status): source = json.dumps(status.data, indent=4) lines = source.splitlines() walker = urwid.SimpleFocusListWalker([ urwid.Text(line) for line in lines ]) super().__init__(walker) class ExceptionStackTrace(urwid.ListBox): """Shows an exception stack trace.""" def __init__(self, ex): lines = traceback.format_exception(etype=type(ex), value=ex, tb=ex.__traceback__) walker = urwid.SimpleFocusListWalker([ urwid.Text(line) for line in lines ]) super().__init__(walker) class StatusDeleteConfirmation(urwid.ListBox): signals = ["delete", "close"] def __init__(self, status): yes = SelectableText("Yes, send it to heck") no = SelectableText("No, I'll spare it for now") urwid.connect_signal(yes, "click", lambda *args: self._emit("delete")) urwid.connect_signal(no, "click", lambda *args: self._emit("close")) walker = urwid.SimpleFocusListWalker([ urwid.AttrWrap(yes, "", "blue_selected"), urwid.AttrWrap(no, "", "blue_selected"), ]) super().__init__(walker) class GotoMenu(urwid.ListBox): signals = [ "home_timeline", "public_timeline", "hashtag_timeline", ] def __init__(self): self.hash_edit = EditBox(caption="Hashtag: ") actions = list(self.generate_actions()) walker = urwid.SimpleFocusListWalker(actions) super().__init__(walker) def get_hashtag(self): return self.hash_edit.edit_text.strip() def generate_actions(self): def _home(button): self._emit("home_timeline") def _local_public(button): self._emit("public_timeline", True) def _global_public(button): self._emit("public_timeline", False) def _hashtag(local): hashtag = self.get_hashtag() if hashtag: self._emit("hashtag_timeline", hashtag, local) else: self.set_focus(4) yield Button("Home timeline", on_press=_home) yield Button("Local public timeline", on_press=_local_public) yield Button("Global public timeline", on_press=_global_public) yield urwid.Divider() yield self.hash_edit yield Button("Local hashtag timeline", on_press=lambda x: _hashtag(True)) yield Button("Public hashtag timeline", on_press=lambda x: _hashtag(False)) class Help(urwid.Padding): def __init__(self): actions = list(self.generate_contents()) walker = urwid.SimpleListWalker(actions) listbox = urwid.ListBox(walker) super().__init__(listbox, left=1, right=1) def generate_contents(self): def h(text): return highlight_keys(text, "cyan") def link(text, url): attr_map = {"link": "link_focused"} text = SelectableText([text, ("link", url)]) urwid.connect_signal(text, "click", lambda t: webbrowser.open(url)) return urwid.AttrMap(text, "", attr_map) yield urwid.Text(("yellow_bold", "toot {}".format(__version__))) yield urwid.Divider() yield urwid.Text(("bold", "General usage")) yield urwid.Divider() yield urwid.Text(h(" [Arrow keys] or [H/J/K/L] to move around and scroll content")) yield urwid.Text(h(" [PageUp] and [PageDown] to scroll content")) yield urwid.Text(h(" [Enter] or [Space] to activate buttons and menu options")) yield urwid.Text(h(" [Esc] or [Q] to go back, close overlays, such as menus and this help text")) yield urwid.Divider() yield urwid.Text(("bold", "General keys")) yield urwid.Divider() yield urwid.Text(h(" [Q] - quit toot")) yield urwid.Text(h(" [G] - go to - switch timelines")) yield urwid.Text(h(" [H] - show this help")) yield urwid.Divider() yield urwid.Text(("bold", "Status keys")) yield urwid.Divider() yield urwid.Text("These commands are applied to the currently focused status.") yield urwid.Divider() yield urwid.Text(h(" [B] - Boost/unboost status")) yield urwid.Text(h(" [C] - Compose new status")) yield urwid.Text(h(" [F] - Favourite/unfavourite status")) yield urwid.Text(h(" [R] - Reply to current status")) yield urwid.Text(h(" [S] - Show text marked as sensitive")) yield urwid.Text(h(" [T] - Show status thread (replies)")) yield urwid.Text(h(" [U] - Show the status data in JSON as received from the server")) yield urwid.Text(h(" [V] - Open status in default browser")) yield urwid.Divider() yield urwid.Text(("bold", "Links")) yield urwid.Divider() yield link("Documentation: ", "https://toot.readthedocs.io/") yield link("Project home: ", "https://github.com/ihabunek/toot/") toot-0.25.2/toot/tui/timeline.py0000644000175000017500000002775013612307410020141 0ustar ihabunekihabunek00000000000000import logging import urwid import webbrowser from toot.utils import format_content from .utils import highlight_hashtags, parse_datetime, highlight_keys from .widgets import SelectableText, SelectableColumns logger = logging.getLogger("toot") class Timeline(urwid.Columns): """ Displays a list of statuses to the left, and status details on the right. """ signals = [ "close", # Close thread "compose", # Compose a new toot "delete", # Delete own status "favourite", # Favourite status "focus", # Focus changed "media", # Display media attachments "menu", # Show a context menu "next", # Fetch more statuses "reblog", # Reblog status "reply", # Compose a reply to a status "source", # Show status source "thread", # Show thread for status ] def __init__(self, name, statuses, focus=0, is_thread=False): self.name = name self.is_thread = is_thread self.statuses = statuses self.status_list = self.build_status_list(statuses, focus=focus) self.status_details = StatusDetails(statuses[focus], is_thread) super().__init__([ ("weight", 40, self.status_list), ("weight", 0, urwid.AttrWrap(urwid.SolidFill("│"), "blue_selected")), ("weight", 60, urwid.Padding(self.status_details, left=1)), ]) def build_status_list(self, statuses, focus): items = [self.build_list_item(status) for status in statuses] walker = urwid.SimpleFocusListWalker(items) walker.set_focus(focus) urwid.connect_signal(walker, "modified", self.modified) return urwid.ListBox(walker) def build_list_item(self, status): item = StatusListItem(status) urwid.connect_signal(item, "click", lambda *args: self._emit("menu", status)) return urwid.AttrMap(item, None, focus_map={ "blue": "green_selected", "green": "green_selected", "yellow": "green_selected", "cyan": "green_selected", None: "green_selected", }) def get_focused_status(self): return self.statuses[self.status_list.body.focus] def get_focused_status_with_counts(self): """Returns a tuple of: * focused status * focused status' index in the status list * length of the status list """ return ( self.get_focused_status(), self.status_list.body.focus, len(self.statuses), ) def modified(self): """Called when the list focus switches to a new status""" status, index, count = self.get_focused_status_with_counts() self.draw_status_details(status) self._emit("focus") def refresh_status_details(self): """Redraws the details of the focused status.""" status = self.get_focused_status() self.draw_status_details(status) def draw_status_details(self, status): self.status_details = StatusDetails(status, self.is_thread) self.contents[2] = urwid.Padding(self.status_details, left=1), ("weight", 60, False) def keypress(self, size, key): status = self.get_focused_status() command = self._command_map[key] # If down is pressed on last status in list emit a signal to load more. # TODO: Consider pre-loading statuses earlier if command in [urwid.CURSOR_DOWN, urwid.CURSOR_PAGE_DOWN]: index = self.status_list.body.focus + 1 count = len(self.statuses) if index >= count: self._emit("next") if key in ("b", "B"): self._emit("reblog", status) return if key in ("c", "C"): self._emit("compose") return if key in ("d", "D"): self._emit("delete", status) return if key in ("f", "F"): self._emit("favourite", status) return if key in ("m", "M"): self._emit("media", status) return if key in ("q", "Q"): self._emit("close") return if key == "esc" and self.is_thread: self._emit("close") return if key in ("r", "R"): self._emit("reply", status) return if key in ("s", "S"): status.original.show_sensitive = True self.refresh_status_details() return if key in ("t", "T"): self._emit("thread", status) return if key in ("u", "U"): self._emit("source", status) return if key in ("v", "V"): if status.original.url: webbrowser.open(status.original.url) return return super().keypress(size, key) def append_status(self, status): self.statuses.append(status) self.status_list.body.append(self.build_list_item(status)) def prepend_status(self, status): self.statuses.insert(0, status) self.status_list.body.insert(0, self.build_list_item(status)) def append_statuses(self, statuses): for status in statuses: self.append_status(status) def get_status_index(self, id): # TODO: This is suboptimal, consider a better way for n, status in enumerate(self.statuses): if status.id == id: return n raise ValueError("Status with ID {} not found".format(id)) def focus_status(self, status): index = self.get_status_index(status.id) self.status_list.body.set_focus(index) def update_status(self, status): """Overwrite status in list with the new instance and redraw.""" index = self.get_status_index(status.id) assert self.statuses[index].id == status.id # Sanity check # Update internal status list self.statuses[index] = status # Redraw list item self.status_list.body[index] = self.build_list_item(status) # Redraw status details if status is focused if index == self.status_list.body.focus: self.draw_status_details(status) def remove_status(self, status): index = self.get_status_index(status.id) assert self.statuses[index].id == status.id # Sanity check del(self.statuses[index]) del(self.status_list.body[index]) self.refresh_status_details() class StatusDetails(urwid.Pile): def __init__(self, status, in_thread): """ Parameters ---------- status : Status The status to render. in_thread : bool Whether the status is rendered from a thread status list. """ self.in_thread = in_thread reblogged_by = status.author if status.reblog else None widget_list = list(self.content_generator(status.original, reblogged_by)) return super().__init__(widget_list) def content_generator(self, status, reblogged_by): if reblogged_by: text = "♺ {} boosted".format(reblogged_by.display_name or reblogged_by.username) yield ("pack", urwid.Text(("gray", text))) yield ("pack", urwid.AttrMap(urwid.Divider("-"), "gray")) if status.author.display_name: yield ("pack", urwid.Text(("green", status.author.display_name))) yield ("pack", urwid.Text(("yellow", status.author.account))) yield ("pack", urwid.Divider()) if status.data["spoiler_text"]: yield ("pack", urwid.Text(status.data["spoiler_text"])) yield ("pack", urwid.Divider()) # Show content warning if status.data["spoiler_text"] and not status.show_sensitive: yield ("pack", urwid.Text(("content_warning", "Marked as sensitive. Press S to view."))) else: for line in format_content(status.data["content"]): yield ("pack", urwid.Text(highlight_hashtags(line))) media = status.data["media_attachments"] if media: for m in media: yield ("pack", urwid.AttrMap(urwid.Divider("-"), "gray")) yield ("pack", urwid.Text([("bold", "Media attachment"), " (", m["type"], ")"])) if m["description"]: yield ("pack", urwid.Text(m["description"])) url = m.get("text_url") or m["url"] yield ("pack", urwid.Text(("link", url))) poll = status.data.get("poll") if poll: yield ("pack", urwid.Divider()) yield ("pack", self.build_linebox(self.poll_generator(poll))) card = status.data.get("card") if card: yield ("pack", urwid.Divider()) yield ("pack", self.build_linebox(self.card_generator(card))) application = status.data.get("application") or {} application = application.get("name") yield ("pack", urwid.AttrWrap(urwid.Divider("-"), "gray")) yield ("pack", urwid.Text([ ("gray", "⤶ {} ".format(status.data["replies_count"])), ("yellow" if status.reblogged else "gray", "♺ {} ".format(status.data["reblogs_count"])), ("yellow" if status.favourited else "gray", "★ {}".format(status.data["favourites_count"])), ("gray", " · {}".format(application) if application else ""), ])) # Push things to bottom yield ("weight", 1, urwid.SolidFill(" ")) options = [ "[B]oost", "[D]elete" if status.is_mine else "", "[F]avourite", "[V]iew", "[T]hread" if not self.in_thread else "", "[R]eply", "So[u]rce", "[H]elp", ] options = " ".join(o for o in options if o) options = highlight_keys(options, "cyan_bold", "cyan") yield ("pack", urwid.Text(options)) def build_linebox(self, contents): contents = urwid.Pile(list(contents)) contents = urwid.Padding(contents, left=1, right=1) return urwid.LineBox(contents) def card_generator(self, card): yield urwid.Text(("green", card["title"].strip())) if card.get("author_name"): yield urwid.Text(["by ", ("yellow", card["author_name"].strip())]) yield urwid.Text("") if card["description"]: yield urwid.Text(card["description"].strip()) yield urwid.Text("") yield urwid.Text(("link", card["url"])) def poll_generator(self, poll): for option in poll["options"]: perc = (round(100 * option["votes_count"] / poll["votes_count"]) if poll["votes_count"] else 0) yield urwid.Text(option["title"]) yield urwid.ProgressBar("", "poll_bar", perc) if poll["expired"]: status = "Closed" else: expires_at = parse_datetime(poll["expires_at"]).strftime("%Y-%m-%d %H:%M") status = "Closes on {}".format(expires_at) status = "Poll · {} votes · {}".format(poll["votes_count"], status) yield urwid.Text(("gray", status)) class StatusListItem(SelectableColumns): def __init__(self, status): created_at = status.created_at.strftime("%Y-%m-%d %H:%M") favourited = ("yellow", "★") if status.original.favourited else " " reblogged = ("yellow", "♺") if status.original.reblogged else " " is_reblog = ("cyan", "♺") if status.reblog else " " is_reply = ("cyan", "⤶") if status.original.in_reply_to else " " return super().__init__([ ("pack", SelectableText(("blue", created_at), wrap="clip")), ("pack", urwid.Text(" ")), ("pack", urwid.Text(favourited)), ("pack", urwid.Text(" ")), ("pack", urwid.Text(reblogged)), ("pack", urwid.Text(" ")), urwid.Text(("green", status.original.account), wrap="clip"), ("pack", urwid.Text(is_reply)), ("pack", urwid.Text(is_reblog)), ("pack", urwid.Text(" ")), ]) toot-0.25.2/toot/tui/utils.py0000644000175000017500000000401113533655522017470 0ustar ihabunekihabunek00000000000000import re import shutil import subprocess from datetime import datetime HASHTAG_PATTERN = re.compile(r'(?>> highlight_keys("[P]rint [V]iew", "blue") >>> [('blue', 'P'), 'rint ', ('blue', 'V'), 'iew'] """ def _gen(): highlighted = False for part in re.split("\\[|\\]", text): if part: if highlighted: yield (high_attr, part) if high_attr else part else: yield (low_attr, part) if low_attr else part highlighted = not highlighted return list(_gen()) def highlight_hashtags(line, attr="hashtag"): return [ (attr, p) if p.startswith("#") else p for p in re.split(HASHTAG_PATTERN, line) ] def show_media(paths): """ Attempt to open an image viewer to show given media files. FIXME: This is not very thought out, but works for me. Once settings are implemented, add an option for the user to configure their prefered media viewer. """ viewer = None potential_viewers = [ "feh", "eog", "display" ] for v in potential_viewers: viewer = shutil.which(v) if viewer: break if not viewer: raise Exception("Cannot find an image viewer") subprocess.run([viewer] + paths) toot-0.25.2/toot/tui/widgets.py0000644000175000017500000000243013604371002017764 0ustar ihabunekihabunek00000000000000import urwid class Clickable: """ Add a `click` signal which is sent when the item is activated or clicked. TODO: make it work on widgets which have other signals. """ signals = ["click"] def keypress(self, size, key): if self._command_map[key] == urwid.ACTIVATE: self._emit('click') return return key def mouse_event(self, size, event, button, x, y, focus): if button == 1: self._emit('click') class SelectableText(Clickable, urwid.Text): _selectable = True class SelectableColumns(Clickable, urwid.Columns): _selectable = True class EditBox(urwid.AttrWrap): """Styled edit box.""" def __init__(self, *args, **kwargs): self.edit = urwid.Edit(*args, **kwargs) return super().__init__(self.edit, "editbox", "editbox_focused") class Button(urwid.AttrWrap): """Styled button.""" def __init__(self, *args, **kwargs): button = urwid.Button(*args, **kwargs) padding = urwid.Padding(button, width=len(args[0]) + 4) return super().__init__(padding, "button", "button_focused") def set_label(self, *args, **kwargs): self.original_widget.original_widget.set_label(*args, **kwargs) self.original_widget.width = len(args[0]) + 4 toot-0.25.2/toot/ui/0000755000175000017500000000000013612310376015567 5ustar ihabunekihabunek00000000000000toot-0.25.2/toot/ui/__init__.py0000644000175000017500000000000013431315561017666 0ustar ihabunekihabunek00000000000000toot-0.25.2/toot/ui/app.py0000644000175000017500000006324013533471273016734 0ustar ihabunekihabunek00000000000000# -*- coding: utf-8 -*- import os import webbrowser from toot import __version__, api from toot.exceptions import ConsoleError from toot.ui.parsers import parse_status from toot.ui.utils import draw_horizontal_divider, draw_lines, size_as_drawn from toot.wcstring import fit_text # Attempt to load curses, which is not available on windows try: import curses import curses.panel import curses.textpad except ImportError: raise ConsoleError("Curses is not available on this platform") class Color: @classmethod def setup_palette(class_): curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLACK) curses.init_pair(2, curses.COLOR_BLUE, curses.COLOR_BLACK) curses.init_pair(3, curses.COLOR_GREEN, curses.COLOR_BLACK) curses.init_pair(4, curses.COLOR_YELLOW, curses.COLOR_BLACK) curses.init_pair(5, curses.COLOR_RED, curses.COLOR_BLACK) curses.init_pair(6, curses.COLOR_CYAN, curses.COLOR_BLACK) curses.init_pair(7, curses.COLOR_MAGENTA, curses.COLOR_BLACK) curses.init_pair(8, curses.COLOR_WHITE, curses.COLOR_BLUE) curses.init_pair(9, curses.COLOR_WHITE, curses.COLOR_RED) class_.WHITE = curses.color_pair(1) class_.BLUE = curses.color_pair(2) class_.GREEN = curses.color_pair(3) class_.YELLOW = curses.color_pair(4) class_.RED = curses.color_pair(5) class_.CYAN = curses.color_pair(6) class_.MAGENTA = curses.color_pair(7) class_.WHITE_ON_BLUE = curses.color_pair(8) class_.WHITE_ON_RED = curses.color_pair(9) class_.HASHTAG = class_.BLUE | curses.A_BOLD class HeaderWindow: def __init__(self, stdscr, height, width, y, x): self.window = stdscr.subwin(height, width, y, x) self.window.bkgdset(' ', Color.WHITE_ON_BLUE) self.height = height self.width = width def draw(self, user): username = "{}@{}".format(user.username, user.instance) self.window.erase() self.window.addstr(" toot", curses.A_BOLD) self.window.addstr(" | ") self.window.addstr(username) self.window.addstr(" | ") self.window.refresh() class FooterWindow: def __init__(self, stdscr, height, width, y, x): self.window = stdscr.subwin(height, width, y, x) self.height = height self.width = width def draw_status(self, selected, count): text = "Showing toot {} of {}".format(selected + 1, count) text = fit_text(text, self.width) self.window.addstr(0, 0, text, Color.WHITE_ON_BLUE | curses.A_BOLD) self.window.refresh() def draw_message(self, text, color): text = fit_text(text, self.width - 1) self.window.addstr(1, 0, text, color) self.window.refresh() def clear_message(self): self.window.addstr(1, 0, "".ljust(self.width - 1)) self.window.refresh() class StatusListWindow: """Window which shows the scrollable list of statuses (left side).""" def __init__(self, stdscr, height, width, top, left): # Dimensions and position of region in stdscr which will contain the pad self.region_height = height self.region_width = width self.region_top = top self.region_left = left # How many statuses fit on one page (excluding border, at 3 lines per status) self.page_size = (height - 2) // 3 # Initially, size the pad to the dimensions of the region, will be # increased later to accomodate statuses self.pad = curses.newpad(10, width) self.pad.box() # Make curses interpret escape sequences for getch (why is this off by default?) self.pad.keypad(True) self.scroll_pos = 0 def draw_statuses(self, statuses, selected, starting=0): # Resize window to accomodate statuses if required height, width = self.pad.getmaxyx() new_height = len(statuses) * 3 + 1 if new_height > height: self.pad.resize(new_height, width) self.pad.box() last_idx = len(statuses) - 1 for index, status in enumerate(statuses): if index >= starting: highlight = selected == index draw_divider = index < last_idx self.draw_status_row(status, index, highlight, draw_divider) def draw_status_row(self, status, index, highlight=False, draw_divider=True): offset = 3 * index height, width = self.pad.getmaxyx() color = Color.GREEN if highlight else Color.WHITE trunc_width = width - 15 acct = fit_text("@" + status['account']['acct'], trunc_width) display_name = fit_text(status['account']['display_name'], trunc_width) if status['account']['display_name']: self.pad.addstr(offset + 1, 14, display_name, color) self.pad.addstr(offset + 2, 14, acct, color) else: self.pad.addstr(offset + 1, 14, acct, color) if status['in_reply_to_id'] is not None: self.pad.addstr(offset + 1, width - 3, '⤶', Color.CYAN) date, time = status['created_at'] self.pad.addstr(offset + 1, 1, " " + date.ljust(12), color) self.pad.addstr(offset + 2, 1, " " + time.ljust(12), color) if status['favourited']: self.pad.addstr(offset + 2, width - 3, '⭐', Color.YELLOW) if draw_divider: draw_horizontal_divider(self.pad, offset + 3) self.refresh() def refresh(self): self.pad.refresh( self.scroll_pos * 3, # top 0, # left self.region_top, self.region_left, self.region_height + 1, # +1 required to refresh full height, not sure why self.region_width, ) def scroll_to(self, index): self.scroll_pos = index self.refresh() def scroll_up(self): if self.scroll_pos > 0: self.scroll_to(self.scroll_pos - 1) def scroll_down(self): self.scroll_to(self.scroll_pos + 1) def scroll_if_required(self, new_index): if new_index < self.scroll_pos: self.scroll_up() elif new_index >= self.scroll_pos + self.page_size: self.scroll_down() else: self.refresh() class StatusDetailWindow: """Window which shows details of a status (right side)""" def __init__(self, stdscr, height, width, y, x): self.window = stdscr.subwin(height, width, y, x) self.height = height self.width = width def content_lines(self, status): acct = status['account']['acct'] name = status['account']['display_name'] if name: yield name, Color.YELLOW yield "@" + acct, Color.GREEN yield text_width = self.width - 4 if status['sensitive']: for line in status['spoiler_text']: yield line yield if status['sensitive'] and not status['show_sensitive']: yield "Marked as sensitive, press s to view".ljust(text_width), Color.WHITE_ON_RED return for line in status['content']: yield line if status['media_attachments']: yield yield "Media:" for attachment in status['media_attachments']: yield attachment['text_url'] or attachment['url'] def footer_lines(self, status): if status['url'] is not None: yield status['url'] if status['boosted_by']: acct = status['boosted_by']['acct'] yield "Boosted by @{}".format(acct), Color.GREEN if status['reblogged']: yield "↷ Boosted", Color.CYAN yield ( "{replies_count} replies, " "{reblogs_count} reblogs, " "{favourites_count} favourites" ).format(**status), Color.CYAN def draw(self, status): self.window.erase() self.window.box() if not status: return content = self.content_lines(status) footer = self.footer_lines(status) y = draw_lines(self.window, content, 1, 2, Color.WHITE) draw_horizontal_divider(self.window, y) draw_lines(self.window, footer, y + 1, 2, Color.WHITE) self.window.refresh() class Modal: def __init__(self, stdscr, resize_callback=None): self.stdscr = stdscr self.resize_callback = resize_callback self.setup_windows() self.full_redraw() self.panel = curses.panel.new_panel(self.window) self.hide() def get_content(self): raise NotImplementedError() def get_size_pos(self, stdscr): screen_height, screen_width = stdscr.getmaxyx() content = self.get_content() height = len(content) + 2 width = max(len(l) for l in content) + 4 y = (screen_height - height) // 2 x = (screen_width - width) // 2 return height, width, y, x def setup_windows(self): height, width, y, x = self.get_size_pos(self.stdscr) self.window = curses.newwin(height, width, y, x) def full_redraw(self): self.setup_windows() self.window.box() draw_lines(self.window, self.get_content(), 1, 2, Color.WHITE) def show(self): self.panel.top() self.panel.show() self.window.refresh() curses.panel.update_panels() def hide(self): self.panel.hide() curses.panel.update_panels() def loop(self): self.show() while True: ch = self.window.getch() key = chr(ch).lower() if curses.ascii.isprint(ch) else None if key == 'q': break elif ch == curses.KEY_RESIZE: if self.resize_callback: self.resize_callback() self.full_redraw() self.hide() class HelpModal(Modal): def get_content(self): return [ ("toot v{}".format(__version__), Color.GREEN | curses.A_BOLD), "", "Key bindings:", "", " h - show help", " j or ↓ - move down", " k or ↑ - move up", " v - view current toot in browser", " b - toggle boost status", " f - toggle favourite status", " c - post a new status", " r - reply to status", " q - quit application", " s - show sensitive content" "", "Press q to exit help.", "", ("https://github.com/ihabunek/toot", Color.YELLOW), ] class DeprecationNoticeModal(Modal): def get_content(self): return [ ("DEPRECATION NOTICE", Color.RED | curses.A_BOLD), "", "This experimental terminal UI has been deprecated and will be ", "removed in the near future.", "", "The new TUI can be lauched by running `toot tui`. This new UI ", "contains all the functionality of this one and much more. ", "It will be supported for the forseeable future.", "", "For details see:", ("https://github.com/ihabunek/toot/pull/108", Color.CYAN), "", ("Press q to close this notice.", Color.YELLOW), ] class EntryModal(Modal): def __init__(self, stdscr, title, footer=None, size=(None, None), default=None, resize_callback=None): self.stdscr = stdscr self.resize_callback = resize_callback self.content = [] if default is None else default.split() self.cursor_pos = 0 self.pad_y, self.pad_x = 2, 2 self.title = title self.footer = footer self.size = size if self.footer: self.pad_y += 1 self.setup_windows() self.full_redraw() self.panel = curses.panel.new_panel(self.window) self.hide() def get_size_pos(self, stdscr): screen_height, screen_width = stdscr.getmaxyx() if self.size[0]: height = self.size[0] + (self.pad_y * 2) + 1 else: height = int(screen_height / 1.33) if self.size[1]: width = self.size[1] + (self.pad_x * 2) + 1 else: width = int(screen_width / 1.25) y = (screen_height - height) // 2 x = (screen_width - width) // 2 return height, width, y, x def setup_windows(self): height, width, y, x = self.get_size_pos(self.stdscr) self.window = curses.newwin(height, width, y, x) self.text_window = self.window.derwin(height - (self.pad_y * 2), width - (self.pad_x * 2), self.pad_y, self.pad_x) self.text_window.keypad(True) def full_redraw(self): self.window.erase() self.window.box() draw_lines(self.window, ["{} (^D to confirm):".format(self.title)], 1, 2, Color.WHITE) if self.footer: window_height, window_width = self.window.getmaxyx() draw_lines(self.window, [self.footer], window_height - self.pad_y + 1, 2, Color.WHITE) self.window.refresh() self.refresh_text() def refresh_text(self): text = self.get_content() lines = text.split('\n') draw_lines(self.text_window, lines, 0, 0, Color.WHITE) text_window_height, text_window_width = self.text_window.getmaxyx() text_on_screen = (''.join(self.content)[:self.cursor_pos] + '_').split('\n') y, x = size_as_drawn(text_on_screen, text_window_width) self.text_window.move(y, x) def show(self): super().show() self.refresh_text() def clear(self): self.content = [] self.cursor_pos = 0 def on_resize(self): if self.resize_callback: self.resize_callback() self.setup_windows() self.full_redraw() def do_command(self, ch): if curses.ascii.isprint(ch) or ch == curses.ascii.LF: text_window_height, text_window_width = self.text_window.getmaxyx() y, x = size_as_drawn((self.get_content() + chr(ch)).split('\n'), text_window_width) if y < text_window_height - 1 and x < text_window_width: self.content.insert(self.cursor_pos, chr(ch)) self.cursor_pos += 1 else: curses.beep() elif ch == curses.KEY_BACKSPACE: if self.cursor_pos > 0: del self.content[self.cursor_pos - 1] self.cursor_pos -= 1 else: curses.beep() elif ch == curses.KEY_DC: if self.cursor_pos >= 0 and self.cursor_pos < len(self.content): del self.content[self.cursor_pos] else: curses.beep() elif ch == curses.KEY_LEFT: if self.cursor_pos > 0: self.cursor_pos -= 1 else: curses.beep() elif ch == curses.KEY_RIGHT: if self.cursor_pos + 1 <= len(self.content): self.cursor_pos += 1 else: curses.beep() elif ch in (curses.ascii.EOT, curses.ascii.RS): # ^D or (for some terminals) Ctrl+Enter return False, False elif ch == curses.ascii.ESC: self.clear() return False, True elif ch == curses.KEY_RESIZE: self.on_resize() return True, False self.refresh_text() return True, False def get_content(self): return ''.join(self.content) def loop(self): self.show() while True: ch = self.text_window.getch() if not ch: continue should_continue, abort_flag = self.do_command(ch) if not should_continue: break self.hide() if abort_flag: return None else: return self.get_content() class ComposeModal(EntryModal): def __init__(self, stdscr, default_cw=None, **kwargs): super().__init__(stdscr, title="Compose a toot", footer="^D to submit, ESC to quit, ^W to mark sensitive (cw)", **kwargs) self.cw = default_cw self.cwmodal = EntryModal(stdscr, title="Content warning", size=(1, 60), default=self.cw, resize_callback=self.on_resize) def do_command(self, ch): if ch == curses.ascii.ctrl(ord('w')): self.cwmodal.on_resize() self.cw = self.cwmodal.loop() or None self.full_redraw() return True, False else: return super().do_command(ch) def loop(self): content = super().loop() return content, self.cw class TimelineApp: def __init__(self, app, user, status_generator): self.app = app self.user = user self.status_generator = status_generator self.statuses = [] self.stdscr = None def run(self): os.environ.setdefault('ESCDELAY', '25') curses.wrapper(self._wrapped_run) def _wrapped_run(self, stdscr): self.stdscr = stdscr Color.setup_palette() self.setup_windows() # Load some data and redraw self.fetch_next() self.selected = 0 self.full_redraw() self.deprecation_modal.loop() self.full_redraw() self.loop() def setup_windows(self): screen_height, screen_width = self.stdscr.getmaxyx() if screen_width < 60: raise ConsoleError("Terminal screen is too narrow, toot curses requires at least 60 columns to display properly.") header_height = 1 footer_height = 2 footer_top = screen_height - footer_height left_width = max(min(screen_width // 3, 60), 30) main_height = screen_height - header_height - footer_height main_width = screen_width - left_width self.header = HeaderWindow(self.stdscr, header_height, screen_width, 0, 0) self.footer = FooterWindow(self.stdscr, footer_height, screen_width, footer_top, 0) self.left = StatusListWindow(self.stdscr, main_height, left_width, header_height, 0) self.right = StatusDetailWindow(self.stdscr, main_height, main_width, header_height, left_width) self.help_modal = HelpModal(self.stdscr, resize_callback=self.on_resize) self.deprecation_modal = DeprecationNoticeModal(self.stdscr, resize_callback=self.on_resize) def loop(self): while True: ch = self.left.pad.getch() key = chr(ch).lower() if curses.ascii.isprint(ch) else None if key == 'q': return elif key == 'h': self.help_modal.loop() self.full_redraw() elif key == 'v': status = self.get_selected_status() if status: webbrowser.open(status['url']) elif key == 'j' or ch == curses.KEY_DOWN: self.select_next() elif key == 'k' or ch == curses.KEY_UP: self.select_previous() elif key == 's': self.show_sensitive() elif key == 'b': self.toggle_reblog() elif key == 'f': self.toggle_favourite() elif key == 'c': self.compose() elif key == 'r': self.reply() elif ch == curses.KEY_RESIZE: self.on_resize() def show_sensitive(self): status = self.get_selected_status() if status['sensitive'] and not status['show_sensitive']: status['show_sensitive'] = True self.right.draw(status) def compose(self): """Compose and submit a new status""" app, user = self.app, self.user if not app or not user: self.footer.draw_message("You must be logged in to post", Color.RED) return compose_modal = ComposeModal(self.stdscr, resize_callback=self.on_resize) content, cw = compose_modal.loop() self.full_redraw() if content is None: return elif len(content) == 0: self.footer.draw_message("Status must contain content", Color.RED) return self.footer.draw_message("Submitting status...", Color.YELLOW) response = api.post_status(app, user, content, spoiler_text=cw, sensitive=cw is not None) status = parse_status(response) self.statuses.insert(0, status) self.selected += 1 self.left.draw_statuses(self.statuses, self.selected) self.footer.draw_message("✓ Status posted", Color.GREEN) def reply(self): """Reply to the selected status""" status = self.get_selected_status() app, user = self.app, self.user if not app or not user: self.footer.draw_message("You must be logged in to reply", Color.RED) return compose_modal = ComposeModal(self.stdscr, default_cw='\n'.join(status['spoiler_text']) or None, resize_callback=self.on_resize) content, cw = compose_modal.loop() self.full_redraw() if content is None: return elif len(content) == 0: self.footer.draw_message("Status must contain content", Color.RED) return self.footer.draw_message("Submitting reply...", Color.YELLOW) response = api.post_status(app, user, content, spoiler_text=cw, sensitive=cw is not None, in_reply_to_id=status['id']) status = parse_status(response) self.statuses.insert(0, status) self.selected += 1 self.left.draw_statuses(self.statuses, self.selected) self.footer.draw_message("✓ Reply posted", Color.GREEN) def toggle_reblog(self): """Reblog or unreblog selected status.""" status = self.get_selected_status() assert status app, user = self.app, self.user if not app or not user: self.footer.draw_message("You must be logged in to reblog", Color.RED) return status_id = status['id'] if status['reblogged']: status['reblogged'] = False self.footer.draw_message("Unboosting status...", Color.YELLOW) api.unreblog(app, user, status_id) self.footer.draw_message("✓ Status unboosted", Color.GREEN) else: status['reblogged'] = True self.footer.draw_message("Boosting status...", Color.YELLOW) api.reblog(app, user, status_id) self.footer.draw_message("✓ Status boosted", Color.GREEN) self.right.draw(status) def toggle_favourite(self): """Favourite or unfavourite selected status.""" status = self.get_selected_status() assert status app, user = self.app, self.user if not app or not user: self.footer.draw_message("You must be logged in to favourite", Color.RED) return status_id = status['id'] if status['favourited']: self.footer.draw_message("Undoing favourite status...", Color.YELLOW) api.unfavourite(app, user, status_id) self.footer.draw_message("✓ Status unfavourited", Color.GREEN) else: self.footer.draw_message("Favourite status...", Color.YELLOW) api.favourite(app, user, status_id) self.footer.draw_message("✓ Status favourited", Color.GREEN) status['favourited'] = not status['favourited'] self.right.draw(status) def select_previous(self): """Move to the previous status in the timeline.""" self.footer.clear_message() if self.selected == 0: self.footer.draw_message("Cannot move beyond first toot.", Color.GREEN) return old_index = self.selected new_index = self.selected - 1 self.selected = new_index self.redraw_after_selection_change(old_index, new_index) def select_next(self): """Move to the next status in the timeline.""" self.footer.clear_message() old_index = self.selected new_index = self.selected + 1 # Load more statuses if no more are available if self.selected + 1 >= len(self.statuses): self.fetch_next() self.left.draw_statuses(self.statuses, self.selected, new_index - 1) self.draw_footer_status() self.selected = new_index self.redraw_after_selection_change(old_index, new_index) def fetch_next(self): try: self.footer.draw_message("Loading toots...", Color.BLUE) statuses = next(self.status_generator) except StopIteration: return None for status in statuses: self.statuses.append(parse_status(status)) self.footer.draw_message("Loaded {} toots".format(len(statuses)), Color.GREEN) return len(statuses) def on_resize(self): self.setup_windows() self.full_redraw() def full_redraw(self): """Perform a full redraw of the UI.""" self.left.draw_statuses(self.statuses, self.selected) self.right.draw(self.get_selected_status()) self.header.draw(self.user) self.draw_footer_status() def redraw_after_selection_change(self, old_index, new_index): old_status = self.statuses[old_index] new_status = self.statuses[new_index] # Perform a partial redraw self.left.draw_status_row(old_status, old_index, highlight=False, draw_divider=False) self.left.draw_status_row(new_status, new_index, highlight=True, draw_divider=False) self.left.scroll_if_required(new_index) self.right.draw(new_status) self.draw_footer_status() def get_selected_status(self): if len(self.statuses) > self.selected: return self.statuses[self.selected] def draw_footer_status(self): self.footer.draw_status(self.selected, len(self.statuses)) toot-0.25.2/toot/ui/parsers.py0000644000175000017500000000242613431524337017627 0ustar ihabunekihabunek00000000000000from toot.utils import format_content def parse_status(status): _status = status.get('reblog') or status account = parse_account(_status['account']) content = list(format_content(_status['content'])) spoiler_text = list(format_content(_status['spoiler_text'])) if _status['spoiler_text'] else [] created_at = status['created_at'][:19].split('T') boosted_by = parse_account(status['account']) if status['reblog'] else None return { 'account': account, 'boosted_by': boosted_by, 'created_at': created_at, 'content': content, 'favourited': status.get('favourited'), 'favourites_count': _status['favourites_count'], 'id': status['id'], 'in_reply_to_id': _status.get('in_reply_to_id'), 'media_attachments': _status['media_attachments'], 'url': _status['url'], 'reblogged': status.get('reblogged'), 'reblogs_count': _status['reblogs_count'], 'replies_count': _status.get('replies_count', 0), 'spoiler_text': spoiler_text, 'sensitive': _status['sensitive'], 'show_sensitive': False, } def parse_account(account): return { 'id': account['id'], 'acct': account['acct'], 'display_name': account['display_name'], } toot-0.25.2/toot/ui/utils.py0000644000175000017500000000407613455346253017320 0ustar ihabunekihabunek00000000000000import re from toot.wcstring import fit_text, wc_wrap def draw_horizontal_divider(window, y): height, width = window.getmaxyx() # Don't draw out of bounds if y < height - 1: line = '├' + '─' * (width - 2) + '┤' window.addstr(y, 0, line) def enumerate_lines(lines, text_width, default_color): def parse_line(line): if isinstance(line, tuple) and len(line) == 2: return line[0], line[1] elif isinstance(line, str): return line, default_color elif line is None: return "", default_color raise ValueError("Wrong yield in generator") def wrap_lines(lines): for line in lines: line, color = parse_line(line) if line: for wrapped in wc_wrap(line, text_width): yield wrapped, color else: yield "", color return enumerate(wrap_lines(lines)) HASHTAG_PATTERN = re.compile(r'(? 0: for wrapped_line in wrapped: x = len(wrapped_line) y += 1 else: x = 0 y += 1 return y - 1, x - 1 if x != 0 else 0 def draw_lines(window, lines, start_y, padding, default_color): height, width = window.getmaxyx() text_width = width - 2 * padding for dy, (line, color) in enumerate_lines(lines, text_width, default_color): y = start_y + dy if y < height - 1: window.addstr(y, padding, fit_text(line, text_width), color) highlight_hashtags(window, y, padding, line) return y + 1 toot-0.25.2/toot/utils.py0000644000175000017500000000545213527533324016677 0ustar ihabunekihabunek00000000000000# -*- coding: utf-8 -*- import os import re import socket import subprocess import tempfile import unicodedata import warnings from bs4 import BeautifulSoup from toot.exceptions import ConsoleError def str_bool(b): """Convert boolean to string, in the way expected by the API.""" return "true" if b else "false" def get_text(html): """Converts html to text, strips all tags.""" # Ignore warnings made by BeautifulSoup, if passed something that looks like # a file (e.g. a dot which matches current dict), it will warn that the file # should be opened instead of passing a filename. with warnings.catch_warnings(): warnings.simplefilter("ignore") text = BeautifulSoup(html.replace(''', "'"), "html.parser").get_text() return unicodedata.normalize('NFKC', text) def parse_html(html): """Attempt to convert html to plain text while keeping line breaks. Returns a list of paragraphs, each being a list of lines. """ paragraphs = re.split("]*>", html) # Convert
s to line breaks and remove empty paragraphs paragraphs = [re.split("
", p) for p in paragraphs if p] # Convert each line in each paragraph to plain text: return [[get_text(l) for l in p] for p in paragraphs] def format_content(content): """Given a Status contents in HTML, converts it into lines of plain text. Returns a generator yielding lines of content. """ paragraphs = parse_html(content) first = True for paragraph in paragraphs: if not first: yield "" for line in paragraph: yield line first = False def domain_exists(name): try: socket.gethostbyname(name) return True except OSError: return False def assert_domain_exists(domain): if not domain_exists(domain): raise ConsoleError("Domain {} not found".format(domain)) EOF_KEY = "Ctrl-Z" if os.name == 'nt' else "Ctrl-D" def multiline_input(): """Lets user input multiple lines of text, terminated by EOF.""" lines = [] while True: try: lines.append(input()) except EOFError: break return "\n".join(lines).strip() EDITOR_INPUT_INSTRUCTIONS = """ # Please enter your toot. Lines starting with '#' will be ignored, and an empty # message aborts the post. """ def editor_input(editor, initial_text): """Lets user input text using an editor.""" initial_text = (initial_text or "") + EDITOR_INPUT_INSTRUCTIONS with tempfile.NamedTemporaryFile() as f: f.write(initial_text.encode()) f.flush() subprocess.run([editor, f.name]) f.seek(0) text = f.read().decode() lines = text.strip().splitlines() lines = (l for l in lines if not l.startswith("#")) return "\n".join(lines) toot-0.25.2/toot/wcstring.py0000644000175000017500000000607313431315573017375 0ustar ihabunekihabunek00000000000000""" Utilities for dealing with string containing wide characters. """ import re from wcwidth import wcwidth, wcswidth def _wc_hard_wrap(line, length): """ Wrap text to length characters, breaking when target length is reached, taking into account character width. Used to wrap lines which cannot be wrapped on whitespace. """ chars = [] chars_len = 0 for char in line: char_len = wcwidth(char) if chars_len + char_len > length: yield "".join(chars) chars = [] chars_len = 0 chars.append(char) chars_len += char_len if chars: yield "".join(chars) def wc_wrap(text, length): """ Wrap text to given length, breaking on whitespace and taking into account character width. Meant for use on a single line or paragraph. Will destroy spacing between words and paragraphs and any indentation. """ line_words = [] line_len = 0 words = re.split(r"\s+", text.strip()) for word in words: word_len = wcswidth(word) if line_words and line_len + word_len > length: line = " ".join(line_words) if line_len <= length: yield line else: yield from _wc_hard_wrap(line, length) line_words = [] line_len = 0 line_words.append(word) line_len += word_len + 1 # add 1 to account for space between words if line_words: line = " ".join(line_words) if line_len <= length: yield line else: yield from _wc_hard_wrap(line, length) def trunc(text, length): """ Truncates text to given length, taking into account wide characters. If truncated, the last char is replaced by an elipsis. """ if length < 1: raise ValueError("length should be 1 or larger") # Remove whitespace first so no unneccesary truncation is done. text = text.strip() text_length = wcswidth(text) if text_length <= length: return text # We cannot just remove n characters from the end since we don't know how # wide these characters are and how it will affect text length. # Use wcwidth to determine how many characters need to be truncated. chars_to_truncate = 0 trunc_length = 0 for char in reversed(text): chars_to_truncate += 1 trunc_length += wcwidth(char) if text_length - trunc_length <= length: break # Additional char to make room for elipsis n = chars_to_truncate + 1 return text[:-n].strip() + '…' def pad(text, length): """Pads text to given length, taking into account wide characters.""" text_length = wcswidth(text) if text_length < length: return text + ' ' * (length - text_length) return text def fit_text(text, length): """Makes text fit the given length by padding or truncating it.""" text_length = wcswidth(text) if text_length > length: return trunc(text, length) if text_length < length: return pad(text, length) return text toot-0.25.2/toot.egg-info/0000755000175000017500000000000013612310376016644 5ustar ihabunekihabunek00000000000000toot-0.25.2/toot.egg-info/PKG-INFO0000644000175000017500000000210413612310376017736 0ustar ihabunekihabunek00000000000000Metadata-Version: 1.2 Name: toot Version: 0.25.2 Summary: Mastodon CLI client Home-page: https://github.com/ihabunek/toot/ Author: Ivan Habunek Author-email: ivan@habunek.com License: GPLv3 Project-URL: Documentation, https://toot.readthedocs.io/en/latest/ Project-URL: Issue tracker, https://github.com/ihabunek/toot/issues/ Description: Toot is a CLI and TUI tool for interacting with Mastodon instances from the command line. Allows posting text and media to the timeline, searching, following, muting and blocking accounts and other actions. Keywords: mastodon toot Platform: UNKNOWN Classifier: Development Status :: 4 - Beta Classifier: Environment :: Console :: Curses Classifier: Environment :: Console Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3) Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: 3.7 Requires-Python: >=3.4 toot-0.25.2/toot.egg-info/SOURCES.txt0000644000175000017500000000233613612310376020534 0ustar ihabunekihabunek00000000000000CHANGELOG.md LICENSE MANIFEST.in Makefile README.rst setup.py tests/__init__.py tests/test_api.py tests/test_auth.py tests/test_config.py tests/test_console.py tests/test_utils.py tests/test_version.py tests/utils.py tests/__pycache__/__init__.cpython-37.pyc tests/__pycache__/test_api.cpython-37-pytest-5.1.2.pyc tests/__pycache__/test_auth.cpython-37-pytest-5.1.2.pyc tests/__pycache__/test_config.cpython-37-pytest-5.1.2.pyc tests/__pycache__/test_console.cpython-37-pytest-5.1.2.pyc tests/__pycache__/test_utils.cpython-37-pytest-5.1.2.pyc tests/__pycache__/test_version.cpython-37-pytest-5.1.2.pyc tests/__pycache__/utils.cpython-37.pyc toot/__init__.py toot/api.py toot/auth.py toot/commands.py toot/config.py toot/console.py toot/exceptions.py toot/http.py toot/logging.py toot/output.py toot/utils.py toot/wcstring.py toot.egg-info/PKG-INFO toot.egg-info/SOURCES.txt toot.egg-info/dependency_links.txt toot.egg-info/entry_points.txt toot.egg-info/requires.txt toot.egg-info/top_level.txt toot/tui/__init__.py toot/tui/app.py toot/tui/compose.py toot/tui/constants.py toot/tui/entities.py toot/tui/overlays.py toot/tui/timeline.py toot/tui/utils.py toot/tui/widgets.py toot/ui/__init__.py toot/ui/app.py toot/ui/parsers.py toot/ui/utils.pytoot-0.25.2/toot.egg-info/dependency_links.txt0000644000175000017500000000000113612310376022712 0ustar ihabunekihabunek00000000000000 toot-0.25.2/toot.egg-info/entry_points.txt0000644000175000017500000000005413612310376022141 0ustar ihabunekihabunek00000000000000[console_scripts] toot = toot.console:main toot-0.25.2/toot.egg-info/requires.txt0000644000175000017500000000012513612310376021242 0ustar ihabunekihabunek00000000000000requests<3.0,>=2.13 beautifulsoup4<5.0,>=4.5.0 wcwidth<2.0,>=0.1.7 urwid<3.0,>=2.0.0 toot-0.25.2/toot.egg-info/top_level.txt0000644000175000017500000000000513612310376021371 0ustar ihabunekihabunek00000000000000toot