├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── pull_request_template.md ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── CNAME ├── _config.yml ├── contributing.md ├── index.md ├── install.md ├── options.md └── upgrade.md ├── example-config.yaml ├── pyproject.toml ├── setup.py ├── src └── ephemetoot │ ├── __init__.py │ ├── console.py │ ├── ephemetoot.py │ └── plist.py └── tests ├── accomplished.jpg └── test_ephemetoot.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: 'BUG - x happens when y' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Description** 11 | A clear description of what the bug is. Provide enough detail so that it is clear what the problem is. Include the exact text of any relevant error messages. 12 | 13 | Please only log one bug per issue. 14 | 15 | **Environment (please complete the following information):** 16 | - OS: [e.g. MacOS 10.15.5 Catalina] 17 | - Python version [e.g. 3.7.7] 18 | 19 | **To Reproduce** 20 | Steps to reproduce the behavior. e.g. run with a particular flag or a particular config value. 21 | 22 | **Expected behavior** 23 | A clear and concise description of what you expected to happen. 24 | 25 | **Actual behavior** 26 | A clear and concise description of what actually happened. 27 | 28 | **Screenshots** 29 | If applicable, add screenshots to help explain your problem. 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature proposal 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | **Does your proposal relate to...** 10 | 11 | - [ ] documentation 12 | - [ ] what is displayed when running ephemetoot 13 | - [ ] a new config value 14 | - [ ] a new flag (option) 15 | - [ ] something else 16 | 17 | **Is your feature request related to a problem? Please describe.** 18 | A clear and concise description of what the problem is. e.g. _I'm always frustrated when [...]_ 19 | 20 | **Describe the solution you'd like** 21 | A clear and concise description of what you want to happen. 22 | 23 | **Would like to write the code yourself?** 24 | 25 | - [ ] I would like to write the code myself and then log a pull request 26 | - [ ] I would like someone else to write the code 27 | - [ ] I would like someone to help me write the code 28 | 29 | **Describe alternatives you've considered** 30 | A clear and concise description of any alternative solutions or features you've considered. 31 | 32 | **Additional context** 33 | Add any other context or screenshots about the feature request here. 34 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Styles and docs 2 | 3 | - [ ] I have read the [contributing guide](https://github.com/hughrun/ephemetoot/blob/master/docs/contributing.md) 4 | - [ ] I have run `black` on my code 5 | - [ ] My tests are listed in alphabetical order 6 | 7 | ## Tests 8 | 9 | - [ ] I have added tests for this code in `tests/test_ephemetoot.py` 10 | - [ ] I would like assistance to write the required tests 11 | - [ ] I don't know how to write test and would like someone to write them for me 12 | 13 | # What this PR does 14 | 15 | Changes in this pull request 16 | - 17 | 18 | # Related issues 19 | 20 | Resolves # 21 | Fixes # -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.yaml 2 | _assets 3 | pypi-readme.md 4 | archive 5 | dist 6 | src/ephemetoot.egg-info 7 | .pytest_cache 8 | .venv -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at `ephemetoot`@`hugh`.`run` All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft src 2 | graft tests 3 | prune src/ephemetoot/__pycache__* 4 | prune tests/__pycache__* 5 | include example-config.yaml 6 | exclude docs/_config.yml docs/CNAME 7 | global-exclude .DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🥳 ==> 🧼 ==> 😇 2 | 3 | **ephemetoot** is a Python command line tool for deleting old toots. 4 | 5 | **As Mastodon now has similar functionality built in, `ephemetoot` is now in maintenance mode - no new features will be added, only security updates.** 6 | 7 | ## Quickstart 8 | 9 | You should have Python3 and pip installed, and an app access token on hand. More detail information is available in [the docs](https://ephemetoot.hugh.run) 10 | 11 | Install with pip: 12 | ```shell 13 | pip install ephemetoot 14 | ``` 15 | Create a config file: 16 | ```shell 17 | ephemetoot --init 18 | ``` 19 | Do a first run in `--test` mode: 20 | ```shell 21 | ephemetoot --test 22 | ``` 23 | Find out about other options: 24 | ```shell 25 | ephemetoot --help 26 | ``` 27 | 28 | ## Documentation 29 | * [Installation](./docs/install.md) 30 | * [Options](./docs/options.md) 31 | * [Upgrading and uninstalling](./docs/upgrade.md) 32 | 33 | You can also read the docs at [ephemetoot.hugh.run](https://ephemetoot.hugh.run) 34 | 35 | ## Prior and related work 36 | The initial `ephemetoot` script was based on [this tweet-deleting script](https://gist.github.com/flesueur/bcb2d9185b64c5191915d860ad19f23f) by [@flesueur](https://github.com/flesueur) 37 | 38 | `ephemetoot` relies heavily on the Mastodon.py package by [@halcy](https://github.com/halcy) 39 | 40 | Looks like [Gabriel Augendre had the same idea](https://git.augendre.info/gaugendre/cleantoots). You might prefer to use Gabriel's `cleantoots` instead. 41 | 42 | ## Usage 43 | You can use `ephemetoot` to delete [Mastodon](https://github.com/tootsuite/mastodon) toots that are older than a certain number of days (default is 365). Toots can optionally be saved from deletion if: 44 | * they are pinned; or 45 | * they include certain hashtags; or 46 | * they have certain visibility; or 47 | * they are individually listed to be kept 48 | 49 | ## Contributing 50 | ephemetoot is tested using `pytest`. 51 | 52 | For bugs or other contributions, please check the [contributing guide](./docs/contributing.md). 53 | 54 | ## License 55 | This project and all contributions are [licensed](./LICENSE) under the GPL 3.0 or future version 56 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | ephemetoot.hugh.run -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-hacker 2 | include: contributing.md -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Thanks for using `ephemetoot`, and for considering contributing to it! Some of the best features have come from suggestions and code contributed by people like you. 4 | 5 | **As Mastodon now has similar functionality built in, `ephemetoot` is now in maintenance mode - no new features will be added, only security updates.** 6 | 7 | You can contribute in many ways - improving the documentation, reporting bugs, suggesting new features, helping test new code, or even writing some code yourself. Following these guidelines will make the process smoother and easier for you and for maintainers and other contributors. That means everyone is happier and improvements get made faster 💫 8 | 9 | # Expectations 10 | 11 | ## Adhere to the Code of Conduct 🤗 12 | All contributors must adhere to the [Code of Conduct](https://github.com/hughrun/ephemetoot/blob/master/CODE_OF_CONDUCT.md) for this project. If you do not wish to follow this Code of Conduct, feel free to direct your energies towards a different project. 13 | 14 | ## Do not log security problems as public issues 15 | If you have identified a security flaw in **ephemetoot**, please email `ephemetoot@hugh.run` to discuss this confidentially. 16 | 17 | ## Check existing issues 🧐 18 | Your bug or enhancement might already be listed in the [issues](https://github.com/hughrun/ephemetoot/issues). It's a good idea to check existing issues before you log your own. If you like someone else's enhancement suggestion, please "upvote" it with a 👍 reaction. If you have also experienced the same bug as someone else, you can add any useful additional context to the existing issue. 19 | 20 | ## Always log an issue 📝 21 | If you would like to contribute code or documentation changes that do not already have an issue listed, you should always [log an issue](https://github.com/hughrun/ephemetoot/issues) first. Please **do not add pull requests without prior discussion**. Whilst pull requests are very welcome and encouraged, if you don't log an issue for discussion first, you may end up wasting your time if someone else is already working on the same feature, or maintainers decide it isn't a good fit. This also allows for your proposed feature to be scoped before you get too deep in the weeds coding it. 22 | 23 | Regardless of whether is is a bug report, feature request or code proposal, provide as much detail as possible in your issue, and give it a clear name. 24 | 25 | ## One issue per bug or suggestion ☝️ 26 | Each issue should refer to a single bug or enhancement. Don't include multiple suggestions, or a mix of bug report and enhancement proposal, in a single issue. Multiple items in the one issue ticket will make it confusing to know when to close an issue, and means that maintainers will probably have to create new issues so that each task can be tracked properly. It also makes it hard to maintain a clear discussion thread in the issue if there are multiple things being discussed. 27 | 28 | ## Issue and commit naming conventions ✏️ 29 | 30 | **Issues** should have clear names that describe the problem or the proposed enhancement. Past examples of good issue titles are: 31 | 32 | - "Ephemetoot may die when encountering utf8 encoded toots" ([bug](https://github.com/hughrun/ephemetoot/issues/11)) 33 | - "Optionally include datetime stamp with every action" ([enhancement](https://github.com/hughrun/ephemetoot/issues/23)) 34 | 35 | **Commit and pull request messages** should start with an [imperative verb](https://www.grammarly.com/blog/imperative-verbs/). Simple commits such as documentation fixes may only need a brief sentence. Something bigger like an enhancement should usually have a heading briefly describing the outcome of the commit, with a longer explanation underneath. Past examples of good commit titles are: 36 | 37 | - "handle IndexError when there are no toots in the timeline" ([bugfix](https://github.com/hughrun/ephemetoot/commit/92643271d53e00089a10bacd1795cfd50e030413)) 38 | - "add support for archiving toots into JSON files" ([new feature](https://github.com/hughrun/ephemetoot/commit/c0d680258ff0fe141fbabcf14a60eee8994e8d18)) 39 | 40 | ## Pull requests should include tests (if you can) ⛳️ 41 | 42 | We aim to have as close to full test coverage as possible: if you know how to write tests, please include them with your Pull Requests. Tests are run with `pytest`, which has [pretty good documentation](https://docs.pytest.org/en/latest/), so if you're new to `pytest` or new to testing, take a look at the docs. If you want to contribute a new fix or feature, but don't know how to rwite a test, you can also request assistance from a maintainer. 43 | 44 | ## Closing issues in pull requests 🏁 45 | 46 | When your pull request resolves an issue, you can optionally use [one of the magic words](https://docs.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword) to automatically close the issue. An example of a longer commit messages that does this is [`Add --version flag`](https://github.com/hughrun/ephemetoot/commit/a1db933bbd6c03e633975463801e6c94f7b9e9fa). The pull request template includes wording for this so you just need to add the issue number. 47 | 48 | ## Use 'black' code formatting standards 🖤 49 | 50 | We use [black](https://pypi.org/project/black/) to maintain code formatting consistency. Thanks to [@MarkEEaton](https://github.com/MarkEEaton) for the suggestion. You should generally run `black .` in the main **ephemetoot** directory before making a pull request, or alternatively check that your code is formatted to the `black` standards. Maintainer [@hughrun](https://github.com/hughrun) often forgets to run `black` so logging an issue about code formatting is completely legitimate 😀 51 | 52 | ## prefer configuration over flags ⚙️ 53 | When adding a new feature, you should probably use a new, _optional_ value in the configuration file, rather than a new command line flag. As a general rule of thumb, use a flag when your change will affect the _output_, and a config value when it will affect the _actions_. 54 | 55 | For example, we use a configuration file boolean value for `keep_pinned` because that affects the _actions_ - if it is set to "true" then pinned toots are not deleted, and if set to "false", pinned toots _are_ deleted. On the other hand we use the `--datestamp` flag to print a datestamp against each action as it is logged. This doesn't change the action, merely the output to the screen or log file. 56 | 57 | There are some exceptions to this general rule (`--test` prevents any real actions, for example), but the exceptions should be rare and reasonably obvious. 58 | 59 | ## Prefer top-level functions ⬆️ 60 | 61 | Putting functions inside other functions can make the codebase confusing to understand. Wherever possible, prefer to define standalone functions and then call them from wherever they need to be used. This keeps our code [DRY](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) and makes it easier to test. 62 | 63 | # Your first contribution 64 | First time contributors are warmly encouraged! If you have never contributed to a project on GitHub or another public code repository, the **ephemetoot** maintainers can help you through the process. 65 | 66 | ## Terminology 📙 67 | You can contribute in many ways - even pointing out where the documentation is unclear will be a real help to future users. Already confused by some of the terms here? Check out [First Timers Only](https://www.firsttimersonly.com) for some tips. 68 | 69 | ## Pull Requests 🤯 70 | "Pull Requests" can be confusing. You can learn how the process works from this free series [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github). 71 | 72 | This is a pretty small project so there usually won't be a lot of issues waiting for someone to work on, but keep an eye out for anything tagged `good first issue` - these are especially for you! 73 | 74 | # Help 75 | 76 | You can get in touch with Hugh at [@hugh@ausglam.space](https://ausglam.space/@hugh) if you need help contributing or want to discuss something about **ephemetoot**. 77 | 78 | --- 79 | * [Home](index.md) 80 | * [Installation](install.md) 81 | * [Options](options.md) 82 | * [Upgrading and uninstalling](upgrade.md) -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) 2 | 3 | **ephemetoot** is a Python command line tool for deleting old toots. 4 | 5 | These docs apply to `ephemetoot` version 3. 6 | 7 | These instructions use the command `pip` - depending on your setup you may need to use `pip3` instead. 8 | 9 | If you are upgrading from an `ephemetoot` version prior to v3.0.0 please see the [upgrading](upgrade.md) instructions and note that you need to manually uninstall the old version first. 10 | 11 | * [Installation](install.md) 12 | * [Options](options.md) 13 | * [Upgrading and uninstalling](upgrade.md) 14 | * [Contributing](contributing.md) 15 | 16 | ## Prior work 17 | The initial `ephemetoot` script was based on [this tweet-deleting script](https://gist.github.com/flesueur/bcb2d9185b64c5191915d860ad19f23f) by [@flesueur](https://github.com/flesueur) 18 | 19 | `ephemetoot` relies heavily on the Mastodon.py package by [@halcy](https://github.com/halcy) 20 | 21 | ## Usage 22 | You can use `ephemetoot` to delete [Mastodon](https://github.com/tootsuite/mastodon) toots that are older than a certain number of days (default is 365). Toots can optionally be saved from deletion if: 23 | * they are pinned; or 24 | * they include certain hashtags; or 25 | * they have certain visibility; or 26 | * they are individually listed to be kept 27 | 28 | ## Rate limits 29 | As of Mastodon v2.7.2 the Mastodon API has a rate limit of 30 deletions per 30 minutes. `mastodon.py` automatically handles this. If you are running `ephemetoot` for the first time and/or have a lot of toots to delete, it may take a while as the script will pause when it hits a rate limit, until the required time has expired. You can use the `--pace` flag to slow down ephemetoot so that it never hits the limit - this is recommended on your first run. It will not speed up the process but will smooth it out. 30 | 31 | Note that the rate limit is per access token, so using ephemetoot for multiple accounts on the same server shouldn't be a big problem, however one new user may delay action on subsequent accounts in the config file. 32 | 33 | ## ASCII / utf-8 errors 34 | Prior to Python 3.7, running a Python script on some BSD and Linux systems may throw an error. This can be resolved by: 35 | * setting a _locale_ that encodes utf-8, by using the environment setting `PYTHONIOENCODING=utf-8` when running the script, or 36 | * upgrading your Python version to 3.7 or higher. See [Issue 11](https://github.com/hughrun/ephemetoot/issues/11) for more information. 37 | 38 | ## Contributing 39 | For all bugs, suggestions, pull requests or other contributions, please check the [contributing guide](./docs/contributing.md). 40 | 41 | ## License 42 | This project and all contributions are [licensed](https://github.com/hughrun/ephemetoot/blob/master/LICENSE) under the GPL 3.0 or future version 43 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | # Setup & Installation 2 | 3 | ## Install Python 3 and pip 4 | 5 | You need to [install Python 3](https://wiki.python.org/moin/BeginnersGuide/Download) to use `ephemetoot`. Python 2 is now end-of-life, however it continued to be installed as the default Python on MacOS and many Linux distributions until very recently, so you should check. 6 | 7 | These instructions use the command `pip` - depending on your setup you may need to use `pip3` instead. 8 | 9 | ## Install ephemetoot from pypi 10 | 11 | ```shell 12 | pip install ephemetoot 13 | ``` 14 | If you do not have permission to install python modules, you may need to use the `--user` flag. Generally this is not advisable, since you will need to run ephemetoot with the same user since it will only be installed for that user and not globally: 15 | 16 | ```shell 17 | pip install ephemetoot --user 18 | ``` 19 | 20 | ## Obtain an access token 21 | 22 | Now you've installed `ephemetoot`, in order to actually use it you will need an application "access token" from each user. Log in to your Mastodon account using a web browser: 23 | 24 | 1. Click the `settings` cog 25 | 2. Click on `Development` 26 | 3. Click `NEW APPLICATION` 27 | 4. Enter an application name (e.g. 'ephemetoot') 28 | 5. The following 'scopes' are required: 29 | - `read:accounts` 30 | - `read:statuses` 31 | - `write:conversations` 32 | - `write:statuses` 33 | 5. Click `SUBMIT` 34 | 6. Click on the name of the new app, which should be a link 35 | 7. Copy the `Your access token` string - you will need this for your configuration file (see below) 36 | 37 | **NOTE**: Anyone who has your access token and the domain name of your Mastodon server will be able to: 38 | * read all your private and direct toots, 39 | * publish toots and DMs from your account, and 40 | * read everything in your account settings. 41 | 42 | **Do not share your access token with anyone you do not 100% trust!!!**. 43 | 44 | ## Configuration file 45 | 46 | By default, configuration for each user is expected to be in a file called `config.yaml` in the path from where `ephemetoot` is run. This uses [yaml syntax](https://yaml.org/spec/1.2/spec.html) and can be updated at any time without having to reload/restart `ephemetoot`. 47 | 48 | You can create a config file by hand, but the easiest way to do it is with the `--init` flag: 49 | 50 | ```shell 51 | ephemetoot --init 52 | ``` 53 | 54 | Calling `--init` will save your configuration file as `config.yaml` in the current directory. Once this file has been created, you may change the name or move the file if you prefer. See [`--config`](./options.html#specify-the-config-location-config) for more detail on using a non-default configuration filepath. 55 | 56 | `--init` will ask you to fill in information for each part of the file: 57 | 58 | | setting | description | 59 | | ---: | :--- | 60 | | access_token | **required** - The alphanumeric access token string from the app you created in Mastodon | 61 | | username | **required** - Your username without the '@' or server domain. e.g. `hugh`| 62 | | base_url | **required** - The base url of your Mastodon server, without the 'https://'. e.g. `ausglam.space`| 63 | | days_to_keep | Number of days to keep toots e.g. `30`. If no value is provided the default number is 365 | 64 | | keep_pinned | Either `true` or `false` - if `true`, any pinned toots will be kept regardless of age | 65 | | boosts_only | Either `true` or `false`. | 66 | | toots_to_keep | A list of toot ids indicating toots to be kept regardless of other settings. The ID of a toot is the last part of its individual URL. e.g. for [https://ausglam.space/@hugh/101294246770105799](https://ausglam.space/@hugh/101294246770105799) the id is `101294246770105799` | 67 | | hashtags_to_keep | A list of hashtags, where any toots with any of these hashtags will be kept regardless of age. Do not include the '#' symbol. Do remember the [rules for hashtags](https://docs.joinmastodon.org/user/posting/#hashtags) | 68 | | visibility_to_keep | Toots with any of the visibility settings in this list will be kept regardless of age. Options are: `public`, `unlisted`, `private`, `direct`. | 69 | | archive | A string representing the filepath to your toot archive. If this is provided, for every toot checked, the full toot is archived into individual files named by the toot's `id` in this writeable directory. Note that the default is for **all** toots to be archived, not just those that are being deleted. It is generally best to use an absolute file path - relative paths will not work if you call `ephemetoot` from another directory. | 70 | | archive_media | Either `true` or `false` - if `true`, media attachments are archived when a toot is archived. | 71 | 72 | All values other than `access_token`, `username` and `base_url` are optional, however if you include `toots_to_keep`, `hashtags_to_keep`, or `visibility_to_keep` you must make each a list, even if it is empty: 73 | 74 | ```yaml 75 | toots_to_keep: # this is not a list, it will throw an error 76 | hashtags_to_keep: 77 | - # this empty list is ok 78 | visibility_to_keep: [ ] # this empty list is also ok 79 | ``` 80 | 81 | As of version 2, you can use a single `ephemetoot` installation to delete toots from multiple accounts. If you want to use `ephemetoot` for multiple accounts, separate the config for each user with a single dash (`-`), and add the additional details, as shown in [the example file](https://github.com/hughrun/ephemetoot/blob/master/example-config.yaml). 82 | 83 | --- 84 | * [Home](index.md) 85 | * [Options](options.md) 86 | * [Upgrading and uninstalling](upgrade.md) 87 | * [Contributing](contributing.md) 88 | -------------------------------------------------------------------------------- /docs/options.md: -------------------------------------------------------------------------------- 1 | # Running the script 2 | 3 | For a short description of all available options, run `ephemetoot --help` from the command line. 4 | 5 | It is **strongly recommended** that you do a test run before using `ephemetoot` live. There is no "undo"! 6 | 7 | ## Create your config file (--init) 8 | 9 | Before you can use `ephemetoot` you need a configuration file. You can create this yourself, but `--init` will walk you through the values you need and save it to `config.yaml` in the directory from which you run the command: 10 | 11 | ```shell 12 | ephemetoot --init 13 | ``` 14 | 15 | More information about the config file can be found on the _[Installation](./install.md#configuration-file)_ page, and an [example file](https://github.com/hughrun/ephemetoot/blob/master/example-config.yaml) is available on GitHub. 16 | 17 | ## Run in test mode (--test) 18 | 19 | To do a test-run without actually deleting anything, run the script with the `--test` flag: 20 | ```shell 21 | ephemetoot --test 22 | ``` 23 | 24 | ## Run in "live" mode 25 | 26 | To call the script use the command `ephemetoot` without any other arguments: 27 | ```shell 28 | ephemetoot 29 | ``` 30 | 31 | Depending on how many toots you have and how long you want to keep them, it may take a minute or two before you see any results. 32 | 33 | ## Specify the config location (--config) 34 | 35 | By default ephemetoot expects there to be a config file called `config.yaml` in the directory from where you run the `ephemetoot` command. If you want to call it from elsewhere (e.g. when using `cron`), you need to specify where your config file is: 36 | 37 | ```shell 38 | ephemetoot --config '~/directory/subdirectory/config.yaml' 39 | ``` 40 | ## Manage timing 41 | 42 | ### Slow down deletes to match API limit (--pace) 43 | 44 | With the `--pace` flag, delete actions are slowed so that the API limit is never reached, essentially borrowing the 'pace' method from the [`Mastodon.py`](https://mastodonpy.readthedocs.io/en/stable/index.html?highlight=pace#mastodon.Mastodon.__init__) module. This is **recommended for your first run**, as unless you have tooted fewer than 30 times you are guaranteed to hit the API limit for deletions the first time you run `ephemetoot`. If you do not toot very often on most days, it is probably more efficient to use the default behaviour for daily runs after the first time, but you can use `--pace` every time if you prefer. 45 | 46 | ### Increase the time between retry attempts when encountering errors (--retry-mins) 47 | 48 | Use `--retry-mins` to increase the period between attempts to retry deletion after an error. The default value is one (1) minute, but you can make it anything you like. This is useful if your mastodon server is unreliable or frequently in "maintenance mode". `ephemetoot` will make four additional attempts if it encounters an error, so the following command, for example, would wait 20 minutes between each retry, allowing the script to continue if there is an outage of 79 minutes or fewer: 49 | 50 | ```shell 51 | ephemetoot --retry-mins 20 52 | ``` 53 | 54 | ## Do more 55 | 56 | ### Include datestamp with every action (--datestamp) 57 | 58 | If you want to know exactly when each delete action occured, you can use the `--datestamp` flag to add a datestamp to the log output. This is useful when using `--pace` so you can see the rate you have been slowed down to. 59 | 60 | ### Include full error messages (--verbose) 61 | 62 | Sometimes you might get an error and want to know more about what's triggering it. Use the `--verbose` flag to print the full error to the console, instead of just the friendly version. 63 | 64 | ## Do less 65 | 66 | ### Hide skipped items (--hide-skipped) 67 | 68 | If you skip a lot of items (e.g. you skip direct messages) it may clutter your log file to list these every time you run the script. You can suppress them from the output by using the `--hide-skipped` flag. 69 | 70 | ### Hide everything (--quiet) 71 | 72 | Use the `--quiet` or `-q` flag to suppress all logging except for the account name being checked and the number of toots deleted. Use the `-qq` flag to further suppress output for accounts with zero deleted toots. The `-qqq` flag will suppress all output. Exception messages will not be suppressed, other than `IndexError` when any account has no toots to check. 73 | 74 | ### Only archive deleted toots (--archive-deleted) 75 | 76 | If you provide a value for `archive` in your config file, the default is that all toots will be archived in that location, regardless of whether or not it is being deleted. i.e. it will create a local archive of your entire toot history. If you run ephemetoot with the `--test` flag, this allows you to use create an archive without even deleting anything. 77 | 78 | You can use the `--archive-deleted` flag to only archive deleted toots instead. 79 | 80 | ## Combining flag options 81 | 82 | You can use several flags together: 83 | ```shell 84 | ephemetoot --config 'directory/config.yaml' --test --hide-skipped 85 | ``` 86 | Use them in any order: 87 | ```shell 88 | ephemetoot --pace --retry-mins 5 --datestamp --config 'directory/config.yaml' 89 | ``` 90 | 91 | ## Scheduling 92 | 93 | Deleting old toots daily is the best approach to keeping your timeline clean and avoiding problems with the API rate limit. 94 | 95 | ### Linux and FreeBSD/Unix 96 | 97 | To run automatically every day on a n*x server you could try using crontab: 98 | 99 | 1. `crontab -e` 100 | 2. enter a new line: `@daily /path/to/ephemetoot --config /path/to/ephemetoot/config.yaml` 101 | 3. exit with `:qw` (Vi/Vim) or `Ctrl + x` (nano) 102 | 103 | ### MacOS (--schedule) 104 | 105 | On **MacOS** you can use the `--schedule` flag to schedule a daily job with [launchd](https://www.launchd.info/). Note that this feature has not been widely tested so **please log an issue if you notice anything go wrong**. 106 | 107 | Run from within your `ephemetoot` directory: 108 | ```shell 109 | ephemetoot --schedule 110 | ``` 111 | or from anywhere else run: 112 | ```shell 113 | ephemetoot --schedule directory 114 | ``` 115 | where `directory` is where you installed `ephemetoot`. 116 | For example if `ephemetoot` is saved to `/User/hugh/python/ephemetoot`: 117 | ```shell 118 | ephemetoot --schedule /User/hugh/python/ephemetoot 119 | ``` 120 | 121 | By default, `ephemetoot` will run at 9am every day (as long as your machine is logged in and connected to the internet). You can change the time it is scheduled to run, using the `--time` flag with `--schedule`: 122 | ```shell 123 | ephemetoot --schedule [directory] --time hour minute 124 | ``` 125 | For example to run at 2.25pm every day: 126 | ```shell 127 | ephemetoot --schedule --time 14 25 128 | ``` 129 | --- 130 | * [Home](index.md) 131 | * [Installation](install.md) 132 | * [Upgrading and uninstalling](upgrade.md) 133 | * [Contributing](contributing.md) 134 | -------------------------------------------------------------------------------- /docs/upgrade.md: -------------------------------------------------------------------------------- 1 | # Upgrade or uninstall 2 | 3 | ## Upgrading 4 | 5 | ### Note for users upgrading to Version 3 6 | 7 | To upgrade from an earlier version to Version 3.x you will need to remove your existing install. 8 | 9 | 1. save a copy of your `config.yaml` file somewhere safe 10 | 2. run `pip uninstall ephemetoot` 11 | 3. run `pip install ephemetoot` 12 | 4. check your config file is in the current directory 13 | 5. check everything is working with `ephemetoot --test` or `ephemetoot --version` 14 | 15 | ### Upgrading with pypi 16 | To upgrade to a new version, the easiest way is to use pip to download the latest version from pypi: 17 | 18 | ```shell 19 | pip install --upgrade ephemetoot 20 | ``` 21 | 22 | ### Upgrading with git 23 | To upgrade to a new version using git, run the following from inside the `ephemetoot` directory: 24 | 25 | ```shell 26 | git fetch --tags 27 | git checkout [latest-tagname] 28 | pip install . 29 | ``` 30 | 31 | ### Upgrading with a ZIP file 32 | To upgrade without using git or pypi: 33 | 34 | * put your config file somewhere safe 35 | * download and unzip the zip file into your `ephemetoot` directory over the top of your existing installation 36 | * move your config file back in to the ephemetoot directory 37 | * run `pip install .` from within the directory 38 | 39 | ## Uninstalling 40 | 41 | Uninstall using pip: 42 | ```shell 43 | pip uninstall ephemetoot 44 | ``` 45 | 46 | If you scheduled a `launchd` job on MacOS using `--schedule`, you will also need to unload and remove the scheduling file: 47 | ```shell 48 | launchctl unload ~/Library/LaunchAgents/ephemetoot.scheduler.plist 49 | rm ~/Library/LaunchAgents/ephemetoot.scheduler.plist 50 | ``` 51 | --- 52 | * [Home](index.md) 53 | * [Installation](install.md) 54 | * [Options](options.md) 55 | * [Contributing](contributing.md) -------------------------------------------------------------------------------- /example-config.yaml: -------------------------------------------------------------------------------- 1 | # access_token : the access token from the app you created in Mastodon at Settings - Development 2 | # username : your username without the "@" or server domain. 3 | # base_url : the base url of your Mastodon server, without the "https://" 4 | # days_to_keep : number of days to keep toots. Defaults to 365 5 | # keep_pinned : either true or false - if true, any pinned toots will be kept (default false) 6 | # toots_to_keep : a list of toot ids indicating toots to be kept regardless of other settings 7 | # hashtags_to_keep : a list of hashtags, where any toots with any of these hashtags will be kept. Do not include the "#" symbol 8 | # visibility_to_keep : any toots with visibility settings in this list will be kept. Options are: "public", "unlisted", "private", "direct" 9 | # archive : path to a writeable directory into which toots are "archived" as JSON files 10 | 11 | # you can list only one user, or multiple users 12 | # each user account should be preceded by a single dash, and indented, as per below 13 | - 14 | # full example 15 | access_token : ZA-Yj3aBD8U8Cm7lKUp-lm9O9BmDgdhHzDeqsY8tlL0 16 | username : alice 17 | base_url : ausglam.space 18 | days_to_keep : 14 19 | keep_pinned : true 20 | toots_to_keep : 21 | - 103996285277439262 22 | - 103976473612749097 23 | - 103877521458738491 24 | hashtags_to_keep : 25 | - python 26 | - glamblogclub 27 | visibility_to_keep : 28 | - direct 29 | - private 30 | archive : Users/alice/toots_archive/ausglam/ 31 | archive_media: true 32 | - 33 | # minimal example 34 | # values other than access_token, username, and base_url are all optional 35 | access_token : AZ-Yj3aBD8U8Cm7lKUp-lm9O9BmDgdhHzDeqsY8tlL9 36 | username : bob 37 | base_url : aus.social 38 | days_to_keep : 30 39 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "ephemetoot" 3 | version = "3.2.1" 4 | description = "A command line tool to delete your old toots" 5 | requires-python = ">=3.8" 6 | authors = [ 7 | {name = "Hugh Rundle", email = "ephemetoot@hugh.run"} 8 | ] 9 | license = { text = "GPL-3.0-or-later"} 10 | readme = "pypi-readme.md" 11 | keywords = ["mastodon", "api", "microblogging"] 12 | classifiers = [ 13 | "Environment :: Console", 14 | "Operating System :: OS Independent", 15 | "Topic :: Communications" 16 | ] 17 | dependencies = [ 18 | "requests>=2.31.0", 19 | "mastodon.py>=1.8.1", 20 | "pyyaml>=6.0.1" 21 | ] 22 | 23 | [project.optional-dependencies] 24 | dev = ["pytest>=6"] 25 | 26 | [project.scripts] 27 | ephemetoot = 'ephemetoot.console:main' 28 | 29 | [project.urls] 30 | homepage = "https://ephemetoot.hugh.run" 31 | repository = "https://github.com/hughrun/ephemetoot" 32 | 33 | [build-system] 34 | requires = ["setuptools>=69.0"] 35 | build-backend = "setuptools.build_meta" 36 | 37 | [tool.setuptools.packages.find] 38 | where = ["src"] -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /src/ephemetoot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hughrun/ephemetoot/75b49ac2107966e3926d470f8bc4df98d96cc1a4/src/ephemetoot/__init__.py -------------------------------------------------------------------------------- /src/ephemetoot/console.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # ##################################################################### 4 | # Ephemetoot - A command line tool to delete your old toots 5 | # Copyright (C) 2018 Hugh Rundle, 2019-2020 Hugh Rundle & others 6 | # Initial work based on tweet-deleting script by @flesueur 7 | # (https://gist.github.com/flesueur/bcb2d9185b64c5191915d860ad19f23f) 8 | # 9 | # This program is free software: you can redistribute it and/or modify 10 | # it under the terms of the GNU General Public License as published by 11 | # the Free Software Foundation, either version 3 of the License, or 12 | # (at your option) any later version. 13 | 14 | # This program is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License for more details. 18 | 19 | # You should have received a copy of the GNU General Public License 20 | # along with this program. If not, see . 21 | 22 | # You can contact Hugh on Mastodon: @hugh@ausglam.space 23 | # or email: ephemetoot@hugh.run 24 | # ##################################################################### 25 | 26 | # import 27 | import yaml 28 | 29 | # from standard library 30 | from argparse import ArgumentParser 31 | from datetime import datetime, timezone 32 | import os 33 | from importlib.metadata import version 34 | 35 | # import funtions 36 | from ephemetoot import ephemetoot as func 37 | 38 | # version number from package info 39 | vnum = version("ephemetoot") 40 | 41 | parser = ArgumentParser() 42 | parser.add_argument( 43 | "--archive-deleted", 44 | action="store_true", 45 | help="Only archive toots that are being deleted", 46 | ) 47 | parser.add_argument( 48 | "--config", 49 | action="store", 50 | metavar="filepath", 51 | default="config.yaml", 52 | help="Filepath of your config file, absolute or relative to the current directory. If no --config path is provided, ephemetoot will use 'config.yaml'in the current directory", 53 | ) 54 | parser.add_argument( 55 | "--datestamp", 56 | action="store_true", 57 | help="Include a datetime stamp for every action (e.g. deleting a toot)", 58 | ) 59 | parser.add_argument( 60 | "--hide-skipped", 61 | "--hide_skipped", 62 | action="store_true", 63 | help="Do not write to log when skipping saved toots", 64 | ) 65 | parser.add_argument( 66 | "--init", 67 | action="store_true", 68 | help="Create a config file that is saved in the current directory", 69 | ) 70 | parser.add_argument( 71 | "--pace", 72 | action="store_true", 73 | help="Slow deletion actions to match API rate limit to avoid pausing", 74 | ) 75 | parser.add_argument( 76 | "-q", 77 | "--quiet", 78 | action="count", 79 | help="Limits logging to one line per account. Use -qq to limit logging to accounts with deleted toots and -qqq to completely suppress logging.", 80 | ) 81 | parser.add_argument( 82 | "--retry-mins", 83 | action="store", 84 | metavar="minutes", 85 | nargs="?", 86 | const="1", 87 | default="1", 88 | type=int, 89 | help="Number of minutes to wait between retries, when an error is thrown", 90 | ) 91 | parser.add_argument( 92 | "--schedule", 93 | action="store", 94 | metavar="filepath", 95 | nargs="?", 96 | const=".", 97 | help="Save and load plist file on MacOS", 98 | ) 99 | parser.add_argument( 100 | "--test", action="store_true", help="Do a test run without deleting any toots" 101 | ) 102 | parser.add_argument( 103 | "--time", 104 | action="store", 105 | metavar=("hour", "minute"), 106 | nargs="*", 107 | help="Hour and minute to schedule: e.g. 9 30 for 9.30am", 108 | ) 109 | parser.add_argument( 110 | "--verbose", 111 | action="store_true", 112 | help="Log more information about errors and exceptions", 113 | ) 114 | parser.add_argument( 115 | "--version", 116 | action="store_true", 117 | help="Display the version numbers of the installed and latest versions", 118 | ) 119 | 120 | options = parser.parse_args() 121 | if options.config[0] == "~": 122 | config_file = os.path.expanduser(options.config) 123 | elif options.config[0] == "/": 124 | # make sure user isn't passing in something dodgy 125 | if os.path.exists(options.config): 126 | config_file = options.config 127 | else: 128 | config_file = "" 129 | else: 130 | config_file = os.path.join(os.getcwd(), options.config) 131 | 132 | 133 | def main(): 134 | """ 135 | Call ephemetoot.check_toots() on each user in the config file, with options set via flags from command line. 136 | """ 137 | try: 138 | 139 | if options.init: 140 | func.init() 141 | elif options.version: 142 | func.version(vnum) 143 | elif options.schedule: 144 | func.schedule(options) 145 | else: 146 | if not options.quiet: 147 | print("") 148 | print("============= EPHEMETOOT v" + vnum + " ================") 149 | print( 150 | "Running at " 151 | + str( 152 | datetime.now(timezone.utc).strftime("%a %d %b %Y %H:%M:%S %z") 153 | ) 154 | ) 155 | print("================================================") 156 | print("") 157 | if options.test: 158 | print("This is a test run...\n") 159 | with open(config_file) as config: 160 | for accounts in yaml.safe_load_all(config): 161 | for user in accounts: 162 | func.check_toots(user, options) 163 | 164 | except FileNotFoundError as err: 165 | 166 | if err.filename == config_file: 167 | print("🕵️ Missing config file") 168 | print("Run \033[92mephemetoot --init\033[0m to create a new one\n") 169 | 170 | else: 171 | print("\n🤷‍♂️ The archive directory in your config file does not exist") 172 | print("Create the directory or correct your config before trying again\n") 173 | 174 | 175 | if __name__ == "__main__": 176 | main() 177 | -------------------------------------------------------------------------------- /src/ephemetoot/ephemetoot.py: -------------------------------------------------------------------------------- 1 | # standard library 2 | from datetime import date, datetime, timedelta, timezone 3 | import json 4 | import os 5 | import re 6 | import urllib.parse 7 | import subprocess 8 | import sys 9 | import time 10 | 11 | # third party 12 | from mastodon import ( 13 | Mastodon, 14 | MastodonError, 15 | MastodonAPIError, 16 | MastodonNetworkError, 17 | MastodonRatelimitError, 18 | ) 19 | import requests 20 | 21 | # local 22 | from ephemetoot import plist 23 | 24 | 25 | def compulsory_input(tags, name, example): 26 | 27 | value = "" 28 | while len(value) < 1: 29 | if example: 30 | value = input(tags[0] + name + tags[1] + example + tags[2]) 31 | else: 32 | value = input(tags[0] + name + tags[2]) 33 | 34 | sanitised = sanitise_input(value, name, tags) 35 | 36 | if len(value) > 0 and (sanitised == "ok" or sanitised == None): 37 | return value 38 | else: 39 | if len(value) > 0 and sanitised != None: 40 | print(sanitised) 41 | value = "" 42 | 43 | 44 | def digit_input(tags, name, example): 45 | 46 | value = "" 47 | while value.isdigit() == False: 48 | if example: 49 | value = input(tags[0] + name + tags[1] + example + tags[2]) 50 | else: 51 | value = input(tags[0] + name + tags[2]) 52 | 53 | return value 54 | 55 | 56 | def yes_no_input(tags, name): 57 | value = "" 58 | while value not in ["y", "n"]: 59 | value = input(tags[0] + name + tags[1] + "(y or n):" + tags[2]) 60 | return_val = "true" if value == "y" else "false" 61 | return return_val 62 | 63 | 64 | def optional_input(tags, name, example): 65 | 66 | incomplete = True 67 | while incomplete: 68 | value = input(tags[0] + name + tags[1] + example + tags[2]) 69 | sanitised = sanitise_input(value, name, tags) 70 | if len(value) > 0 and (sanitised == "ok" or sanitised == None): 71 | incomplete = False 72 | return value 73 | elif len(value) > 0 and sanitised != None: 74 | print(sanitised) 75 | else: 76 | return "" 77 | 78 | 79 | def sanitise_input(value, input_type, tags): 80 | """ 81 | Check that data entered when running --init complies with requirements 82 | """ 83 | 84 | if input_type == "Username": 85 | return ( 86 | "Do not include '@' in username, please try again" 87 | if value.startswith("@") 88 | else "ok" 89 | ) 90 | 91 | if input_type == "Base URL": 92 | error = value.startswith("http") or value.find(".") == -1 93 | return ( 94 | "Provide full domain without protocol prefix (e.g. " 95 | + tags[1] 96 | + "example.social" 97 | + tags[2] 98 | + ", not " 99 | + tags[1] 100 | + "http://example.social" 101 | + tags[2] 102 | + ")" 103 | if error 104 | else "ok" 105 | ) 106 | 107 | if input_type == "Toots to keep": 108 | l = value.split(",") 109 | 110 | def check(s): 111 | d = s.strip() 112 | if not d.isdigit(): 113 | return False 114 | 115 | allnum = map(check, l) 116 | return ( 117 | "Toot IDs must be numeric and separated with commas" 118 | if False in list(allnum) 119 | else "ok" 120 | ) 121 | 122 | if input_type == "Hashtags to keep": 123 | l = value.split(",") 124 | 125 | def check(s): 126 | d = s.strip() 127 | if d.isdigit(): 128 | return False 129 | if not re.fullmatch(r"[\w]+", d, flags=re.IGNORECASE): 130 | return False 131 | 132 | complies = map(check, l) 133 | return_string = ( 134 | "Hashtags must not include '#' and must match rules at " 135 | + tags[0] 136 | + "https://docs.joinmastodon.org/user/posting/#hashtags" 137 | + tags[2] 138 | ) 139 | return return_string if False in list(complies) else "ok" 140 | 141 | if input_type == "Visibility to keep": 142 | l = value.split(",") 143 | viz_options = set(["public", "unlisted", "private", "direct"]) 144 | 145 | def check(s): 146 | d = [s.strip().lower()] 147 | intersects = viz_options.intersection(d) 148 | if len(intersects) == 0: 149 | return False 150 | 151 | complies = map(check, l) 152 | return_string = "Valid values are one or more of 'public', 'unlisted', 'private' or 'direct'" 153 | return return_string if False in list(complies) else "ok" 154 | 155 | if input_type == "Archive path": 156 | path = ( 157 | os.path.expanduser(value) 158 | if len(str(value)) > 0 and str(value)[0] == "~" 159 | else value 160 | ) 161 | response = ( 162 | "ok" 163 | if os.path.exists(path) 164 | else "That directory does not exist, please try again" 165 | ) 166 | return response 167 | 168 | 169 | def init(): 170 | """ 171 | Creates a config.yaml file in the current directory, based on user input. 172 | """ 173 | try: 174 | 175 | # text colour markers (beginning, example, end) 176 | tags = ("\033[96m", "\033[2m", "\033[0m") 177 | 178 | print("\nCreate your config.yaml file.\n") 179 | print( 180 | "For help check out the docs at ", 181 | tags[0], 182 | "ephemetoot.hugh.run", 183 | tags[2], 184 | "\n", 185 | sep="", 186 | ) 187 | 188 | conf_token = compulsory_input(tags, "Access token: ", None) 189 | conf_user = compulsory_input( 190 | tags, "Username", "(without the '@' - e.g. alice):" 191 | ) 192 | conf_url = compulsory_input(tags, "Base URL", "(e.g. example.social):") 193 | conf_days = digit_input(tags, "Days to keep", "(default 365):") 194 | conf_pinned = yes_no_input(tags, "Keep pinned toots?") 195 | conf_boosts_only = yes_no_input(tags, "Only remove boosted toots?") 196 | conf_keep_toots = optional_input( 197 | tags, "Toots to keep", "(optional list of IDs separated by commas):" 198 | ) 199 | conf_keep_hashtags = optional_input( 200 | tags, 201 | "Hashtags to keep", 202 | "(optional list without '#' e.g. mastodon, gardening, cats):", 203 | ) 204 | conf_keep_visibility = optional_input( 205 | tags, "Visibility to keep", "(optional list e.g. 'direct'):" 206 | ) 207 | conf_archive = optional_input( 208 | tags, "Archive path", "(optional filepath for archive):" 209 | ) 210 | 211 | if len(conf_archive) > 0: 212 | conf_archive_media = yes_no_input(tags, "Archive media?") 213 | 214 | # write out the config file 215 | with open("config.yaml", "w") as configfile: 216 | 217 | configfile.write("-") 218 | configfile.write("\n access_token: " + conf_token) 219 | configfile.write("\n username: " + conf_user) 220 | configfile.write("\n base_url: " + conf_url) 221 | configfile.write("\n days_to_keep: " + conf_days) 222 | configfile.write("\n keep_pinned: " + conf_pinned) 223 | configfile.write("\n boosts_only: " + conf_boosts_only) 224 | 225 | if len(conf_keep_toots) > 0: 226 | keep_list = conf_keep_toots.split(",") 227 | configfile.write("\n toots_to_keep:") 228 | for toot in keep_list: 229 | configfile.write("\n - " + toot.strip()) 230 | 231 | if len(conf_keep_hashtags) > 0: 232 | tag_list = conf_keep_hashtags.split(",") 233 | configfile.write("\n hashtags_to_keep:") 234 | for tag in tag_list: 235 | configfile.write("\n - " + tag.strip()) 236 | 237 | if len(conf_keep_visibility) > 0: 238 | viz_list = conf_keep_visibility.split(",") 239 | configfile.write("\n visibility_to_keep:") 240 | for mode in viz_list: 241 | configfile.write("\n - " + mode.strip()) 242 | 243 | if len(conf_archive) > 0: 244 | configfile.write("\n archive: " + conf_archive) 245 | configfile.write("\n archive_media: " + conf_archive_media) 246 | 247 | configfile.close() 248 | 249 | except Exception as e: 250 | print(e) 251 | 252 | 253 | def version(vnum): 254 | """ 255 | Prints current and latest version numbers to console. 256 | """ 257 | 258 | try: 259 | latest = requests.get( 260 | "https://api.github.com/repos/hughrun/ephemetoot/releases/latest" 261 | ) 262 | res = latest.json() 263 | latest_version = res["tag_name"] 264 | print("\nephemetoot ==> 🥳 ==> 🧼 ==> 😇") 265 | print("-------------------------------") 266 | print("You are using release: \033[92mv", vnum, "\033[0m", sep="") 267 | print("The latest release is: \033[92m" + latest_version + "\033[0m") 268 | print( 269 | "To upgrade to the most recent version run \033[92mpip install --upgrade ephemetoot\033[0m" 270 | ) 271 | 272 | except Exception as e: 273 | print("Something went wrong:", e) 274 | 275 | 276 | def schedule(options): 277 | """ 278 | Creates and loads a plist file for scheduled running with launchd. If --time flag is used, the scheduled time is set accordingly. Note that this is designed for use on MacOS. 279 | """ 280 | try: 281 | 282 | if options.schedule == ".": 283 | working_dir = os.getcwd() 284 | else: 285 | working_dir = options.schedule 286 | 287 | lines = plist.default_file.splitlines() 288 | lines[7] = " " + working_dir + "" 289 | lines[10] = " " + sys.argv[0] + "" 290 | lines[12] = " " + working_dir + "/config.yaml" 291 | lines[15] = " " + working_dir + "/ephemetoot.log" 292 | lines[17] = " " + working_dir + "/ephemetoot.error.log" 293 | 294 | if options.time: 295 | lines[21] = " " + options.time[0] + "" 296 | lines[23] = " " + options.time[1] + "" 297 | 298 | # write out file directly to ~/Library/LaunchAgents 299 | f = open( 300 | os.path.join( 301 | os.path.expanduser("~/Library/LaunchAgents"), 302 | "ephemetoot.scheduler.plist", 303 | ), 304 | mode="w", 305 | ) 306 | for line in lines: 307 | if line == lines[-1]: 308 | f.write(line) 309 | else: 310 | f.write(line + "\n") 311 | f.close() 312 | sys.tracebacklimit = 0 # suppress Tracebacks 313 | # unload any existing file (i.e. if this is an update to the file) and suppress any errors 314 | subprocess.run( 315 | ["launchctl unload ~/Library/LaunchAgents/ephemetoot.scheduler.plist"], 316 | stdout=subprocess.DEVNULL, 317 | stderr=subprocess.DEVNULL, 318 | shell=True, 319 | ) 320 | # load the new file 321 | subprocess.run( 322 | ["launchctl load ~/Library/LaunchAgents/ephemetoot.scheduler.plist"], 323 | shell=True, 324 | ) 325 | print("⏰ Scheduled!") 326 | except Exception as e: 327 | print("🙁 Scheduling failed.", e) 328 | if options.verbose: 329 | print(e) 330 | 331 | 332 | def archive_toot_media(archive_path, full_url): 333 | url = urllib.parse.urlparse(full_url) 334 | (dir_name, file_name) = os.path.split(url.path) 335 | media_archive_path = os.path.join(archive_path, url.netloc, dir_name[1:]) 336 | media_archive_file_path = os.path.join(media_archive_path, file_name) 337 | if os.path.isfile(media_archive_file_path): 338 | return 339 | os.makedirs(media_archive_path, exist_ok=True) 340 | r = requests.get(full_url) 341 | with open(media_archive_file_path, "wb") as f: 342 | f.write(r.content) 343 | 344 | 345 | def archive_toot(config, toot): 346 | archive_media = "archive_media" in config and config["archive_media"] 347 | 348 | # define archive path 349 | if config["archive"][0] == "~": 350 | archive_path = os.path.expanduser(config["archive"]) 351 | elif config["archive"][0] == "/": 352 | archive_path = config["archive"] 353 | else: 354 | archive_path = os.path.join(os.getcwd(), config["archive"]) 355 | if archive_path[-1] != "/": 356 | archive_path += "/" 357 | 358 | filename = os.path.join(archive_path, str(toot.id) + ".json") 359 | 360 | # write to file 361 | with open(filename, "w") as f: 362 | f.write(json.dumps(toot, indent=4, default=jsondefault)) 363 | f.close() 364 | 365 | if archive_media and "media_attachments" in toot: 366 | for media_attachment in toot["media_attachments"]: 367 | if "url" in media_attachment: 368 | archive_toot_media(archive_path, media_attachment["url"]) 369 | 370 | 371 | def jsondefault(obj): 372 | if isinstance(obj, (date, datetime)): 373 | return obj.isoformat() 374 | 375 | 376 | def tooted_date(toot): 377 | return toot.created_at.strftime("%d %b %Y") 378 | 379 | 380 | def datestamp_now(): 381 | return str(datetime.now(timezone.utc).strftime("%a %d %b %Y %H:%M:%S %z")) 382 | 383 | 384 | def console_print(msg, options, skip): 385 | 386 | skip_announcement = True if (options.hide_skipped and skip) else False 387 | if not (skip_announcement or options.quiet): 388 | 389 | if options.datestamp: 390 | msg = datestamp_now() + " : " + msg 391 | 392 | print(msg) 393 | 394 | 395 | def print_rate_limit_message(mastodon): 396 | 397 | now = time.time() 398 | diff = mastodon.ratelimit_reset - now 399 | 400 | print( 401 | "\nRate limit reached at", 402 | datestamp_now(), 403 | "- next reset due in", 404 | str(format(diff / 60, ".0f")), 405 | "minutes.\n", 406 | ) 407 | 408 | 409 | def retry_on_error(options, mastodon, toot, attempts=0): 410 | 411 | if attempts < 6: 412 | try: 413 | console_print( 414 | "Attempt " + str(attempts) + " at " + datestamp_now(), options, False 415 | ) 416 | mastodon.status_delete(toot) 417 | except: 418 | attempts += 1 419 | time.sleep(60 * options.retry_mins) 420 | retry_on_error(options, mastodon, toot, attempts) 421 | else: 422 | raise TimeoutError("Gave up after 5 attempts") 423 | 424 | 425 | def process_toot(config, options, mastodon, toot, deleted_count=0): 426 | 427 | keep_pinned = "keep_pinned" in config and config["keep_pinned"] 428 | boosts_only = "boosts_only" in config and config["boosts_only"] 429 | toots_to_keep = config["toots_to_keep"] if "toots_to_keep" in config else [] 430 | visibility_to_keep = ( 431 | config["visibility_to_keep"] if "visibility_to_keep" in config else [] 432 | ) 433 | hashtags_to_keep = ( 434 | set(config["hashtags_to_keep"]) if "hashtags_to_keep" in config else set() 435 | ) 436 | days_to_keep = config["days_to_keep"] if "days_to_keep" in config else 365 437 | cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_to_keep) 438 | 439 | if toot.id and "archive" in config: 440 | 441 | if not options.archive_deleted: 442 | # write toot to archive 443 | archive_toot(config, toot) 444 | 445 | toot_tags = set() 446 | for tag in toot.tags: 447 | toot_tags.add(tag.name) 448 | 449 | try: 450 | if keep_pinned and hasattr(toot, "pinned") and toot.pinned: 451 | console_print("📌 skipping pinned toot - " + str(toot.id), options, True) 452 | 453 | elif toot.id in toots_to_keep: 454 | console_print("💾 skipping saved toot - " + str(toot.id), options, True) 455 | 456 | elif toot.visibility in visibility_to_keep: 457 | console_print( 458 | "👀 skipping " + toot.visibility + " toot - " + str(toot.id), 459 | options, 460 | True, 461 | ) 462 | 463 | elif len(hashtags_to_keep.intersection(toot_tags)) > 0: 464 | console_print( 465 | "#️⃣ skipping toot with hashtag - " + str(toot.id), options, True 466 | ) 467 | 468 | elif cutoff_date > toot.created_at: 469 | if hasattr(toot, "reblog") and toot.reblog: 470 | console_print( 471 | "👎 unboosting toot " 472 | + str(toot.id) 473 | + " boosted " 474 | + tooted_date(toot), 475 | options, 476 | False, 477 | ) 478 | 479 | deleted_count += 1 480 | # unreblog the original toot (their toot), not the toot created by boosting (your toot) 481 | if not options.test: 482 | if mastodon.ratelimit_remaining == 0: 483 | console_print( 484 | "Rate limit reached. Waiting for a rate limit reset", 485 | options, 486 | False, 487 | ) 488 | 489 | # check for --archive-deleted 490 | if options.archive_deleted and "id" in toot and "archive" in config: 491 | # write toot to archive 492 | archive_toot(config, toot) 493 | 494 | mastodon.status_unreblog(toot.reblog) 495 | 496 | elif not boosts_only: 497 | console_print( 498 | "❌ deleting toot " + str(toot.id) + " tooted " + tooted_date(toot), 499 | options, 500 | False, 501 | ) 502 | 503 | deleted_count += 1 504 | time.sleep( 505 | 2 506 | ) # wait 2 secs between deletes to be a bit nicer to the server 507 | 508 | if not options.test: 509 | # deal with rate limits 510 | if mastodon.ratelimit_remaining == 0 and not options.quiet: 511 | print_rate_limit_message(mastodon) 512 | 513 | # check for --archive-deleted 514 | if options.archive_deleted and "id" in toot and "archive" in config: 515 | archive_toot(config, toot) 516 | 517 | # finally we actually delete the toot 518 | mastodon.status_delete(toot) 519 | 520 | # return the deleted_count back so that it can be tallied within check_batch() 521 | return deleted_count 522 | 523 | except MastodonRatelimitError: 524 | 525 | print_rate_limit_message(mastodon) 526 | time.sleep(diff + 1) # wait for rate limit to reset 527 | 528 | # If a server goes offline for maintenance etc halfway through a run, we don't necessarily 529 | # want to just error out. Handling it here allows us to give it time to sort itself out. 530 | except MastodonError as e: 531 | 532 | if options.verbose: 533 | print("🛑 ERROR deleting toot -", str(toot.id), "\n", e) 534 | else: 535 | print( 536 | "🛑 ERROR deleting toot -", 537 | str(toot.id), 538 | "-", 539 | str(e.args[0]), 540 | "-", 541 | str(e.args[3]), 542 | ) 543 | 544 | console_print( 545 | "Waiting " + str(options.retry_mins) + " minutes before re-trying", 546 | options, 547 | False, 548 | ) 549 | time.sleep(60 * options.retry_mins) 550 | retry_on_error(options, mastodon, toot, attempts=2) 551 | 552 | 553 | def check_batch(config, options, mastodon, user_id, timeline, deleted_count=0): 554 | """ 555 | Check a batch of up to 40 toots. This is usually triggered by check_toots, and then recursively calls itself until all toots within the time period specified have been checked. 556 | """ 557 | 558 | try: 559 | for toot in timeline: 560 | # process_toot returns the value of the deleted_count so we can keep track here 561 | deleted_count = process_toot(config, options, mastodon, toot, deleted_count) 562 | 563 | # the account_statuses call is paginated with a 40-toot limit 564 | # get the id of the last toot to include as 'max_id' in the next API call. 565 | # then keep triggering new rounds of check_toots() until there are no more toots to check 566 | max_id = timeline[-1:][0].id 567 | next_batch = mastodon.account_statuses(user_id, limit=40, max_id=max_id) 568 | if len(next_batch) > 0: 569 | check_batch(config, options, mastodon, user_id, next_batch, deleted_count) 570 | else: 571 | if not options.test: 572 | if options.datestamp: 573 | print("\n", datestamp_now(), end=" : ") 574 | 575 | # options.quiet can be None 576 | if ( 577 | (not options.quiet) 578 | or options.quiet <= 1 579 | or (options.quiet == 2 and deleted_count) 580 | ): 581 | print( 582 | "Removed " 583 | + str(deleted_count) 584 | + " toots for " 585 | + config["username"] 586 | + "@" 587 | + config["base_url"] 588 | + ".\n" 589 | ) 590 | 591 | if not options.quiet: 592 | print("---------------------------------------") 593 | print("🥳 ==> 🧼 ==> 😇 User cleanup complete!") 594 | print("---------------------------------------\n") 595 | 596 | else: 597 | 598 | if options.quiet: 599 | if options.datestamp: 600 | print("\n", datestamp_now(), sep="", end=" : ") 601 | 602 | print( 603 | "Test run completed. This would have removed", 604 | str(deleted_count), 605 | "toots.\n", 606 | ) 607 | 608 | else: 609 | print("---------------------------------------") 610 | print("🥳 ==> 🧪 ==> 📋 Test run complete!") 611 | print("This would have removed", str(deleted_count), "toots.") 612 | print("---------------------------------------\n") 613 | 614 | except IndexError: 615 | if not options.quiet or options.quiet <= 1: 616 | print( 617 | "No toots found for " 618 | + config["username"] 619 | + "@" 620 | + config["base_url"] 621 | + ".\n" 622 | ) 623 | 624 | 625 | def check_toots(config, options, retry_count=0): 626 | """ 627 | The main function, uses the Mastodon API to check all toots in the user timeline, and delete any that do not meet any of the exclusion criteria from the config file. 628 | """ 629 | try: 630 | if not options.quiet: 631 | print( 632 | "Fetching account details for @", 633 | config["username"], 634 | "@", 635 | config["base_url"], 636 | sep="", 637 | ) 638 | 639 | if options.pace: 640 | mastodon = Mastodon( 641 | access_token=config["access_token"], 642 | api_base_url="https://" + config["base_url"], 643 | ratelimit_method="pace", 644 | ) 645 | else: 646 | mastodon = Mastodon( 647 | access_token=config["access_token"], 648 | api_base_url="https://" + config["base_url"], 649 | ratelimit_method="wait", 650 | ) 651 | 652 | user_id = mastodon.account_verify_credentials().id # verify user and get ID 653 | account = mastodon.account(user_id) # get the account 654 | timeline = mastodon.account_statuses(user_id, limit=40) # initial batch 655 | 656 | if not options.quiet: 657 | print("Checking", str(account.statuses_count), "toots") 658 | 659 | # check first batch 660 | # check_batch() then recursively keeps looping until all toots have been checked 661 | check_batch(config, options, mastodon, user_id, timeline) 662 | 663 | except KeyboardInterrupt: 664 | print("Operation aborted.") 665 | 666 | except KeyError as val: 667 | print("\n⚠️ error with in your config.yaml file!") 668 | print("Please ensure there is a value for " + str(val) + "\n") 669 | 670 | except MastodonAPIError as e: 671 | if e.args[1] == 401: 672 | print( 673 | "\n🙅 User and/or access token does not exist or has been deleted (401)\n" 674 | ) 675 | elif e.args[1] == 404: 676 | print("\n🔭 Can't find that server (404)\n") 677 | else: 678 | print("\n😕 Server has returned an error (5xx)\n") 679 | 680 | if options.verbose: 681 | print(e, "\n") 682 | 683 | except MastodonNetworkError as e: 684 | if retry_count == 0: 685 | print("\n📡 ephemetoot cannot connect to the server - are you online?") 686 | if options.verbose: 687 | print(e) 688 | if retry_count < 4: 689 | print("Waiting " + str(options.retry_mins) + " minutes before trying again") 690 | time.sleep(60 * options.retry_mins) 691 | retry_count += 1 692 | print("Attempt " + str(retry_count + 1)) 693 | check_toots(config, options, retry_count) 694 | else: 695 | print("Gave up waiting for network\n") 696 | 697 | except Exception as e: 698 | if options.verbose: 699 | print("ERROR:", e) 700 | else: 701 | print("ERROR:", str(e.args[0]), "\n") 702 | -------------------------------------------------------------------------------- /src/ephemetoot/plist.py: -------------------------------------------------------------------------------- 1 | default_file = """ 2 | 3 | 4 | 5 | Label 6 | ephemetoot.scheduler 7 | WorkingDirectory 8 | /FILEPATH/ephemetoot 9 | ProgramArguments 10 | 11 | /usr/local/bin/ephemetoot 12 | --config 13 | config.yaml 14 | 15 | StandardOutPath 16 | ephemetoot.log 17 | StandardErrorPath 18 | ephemetoot.error.log 19 | StartCalendarInterval 20 | 21 | Hour 22 | 9 23 | Minute 24 | 00 25 | 26 | 27 | """ 28 | -------------------------------------------------------------------------------- /tests/accomplished.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hughrun/ephemetoot/75b49ac2107966e3926d470f8bc4df98d96cc1a4/tests/accomplished.jpg -------------------------------------------------------------------------------- /tests/test_ephemetoot.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from datetime import timezone 3 | from dateutil.tz import tzutc 4 | import json 5 | import os 6 | import subprocess 7 | import sys 8 | 9 | import pytest 10 | import requests 11 | 12 | from ephemetoot import ephemetoot 13 | 14 | 15 | ######################## 16 | # MOCKS # 17 | ######################## 18 | 19 | toot_dict = { 20 | "id": 104136090490756999, 21 | "created_at": datetime.datetime(2020, 5, 9, 2, 17, 18, 598000, tzinfo=tzutc()), 22 | "in_reply_to_id": None, 23 | "in_reply_to_account_id": None, 24 | "sensitive": False, 25 | "spoiler_text": "", 26 | "visibility": "public", 27 | "language": "en", 28 | "uri": "https://example.social/users/testbot/statuses/104136090490756503", 29 | "url": "https://example.social/@testbot/104136090490756503", 30 | "replies_count": 0, 31 | "reblogs_count": 0, 32 | "favourites_count": 0, 33 | "favourited": False, 34 | "reblogged": False, 35 | "muted": False, 36 | "bookmarked": False, 37 | "pinned": True, 38 | "content": "

hello I am testing

", 39 | "reblog": None, 40 | "application": None, 41 | "account": { 42 | "id": 16186, 43 | "username": "testbot", 44 | "acct": "testbot", 45 | "display_name": "ephemtoot Testing Bot", 46 | "locked": True, 47 | "bot": True, 48 | "discoverable": False, 49 | "group": False, 50 | "created_at": datetime.datetime( 51 | 2018, 11, 16, 23, 15, 15, 718000, tzinfo=tzutc() 52 | ), 53 | "note": "

Liable to explode at any time, handle with care.

", 54 | "url": "https://example.social/@testbot", 55 | "avatar": "https://example.social/system/accounts/avatars/000/016/186/original/66d11c4191332e7a.png?1542410869", 56 | "avatar_static": "https://example.social/system/accounts/avatars/000/016/186/original/66d11c4191332e7a.png?1542410869", 57 | "header": "https://example.social/headers/original/header.png", 58 | "header_static": "https://example.social/headers/original/header.png", 59 | "followers_count": 100, 60 | "following_count": 10, 61 | "statuses_count": 99, 62 | "last_status_at": datetime.datetime(2020, 8, 17, 0, 0), 63 | "emojis": [], 64 | "fields": [ 65 | {"name": "Fully", "value": "Automated", "verified_at": None}, 66 | {"name": "Luxury", "value": "Communism", "verified_at": None}, 67 | ], 68 | }, 69 | "media_attachments": [ 70 | { 71 | "id": 123456789987654321, 72 | "type": "image", 73 | "url": "https://hugh.run/success/accomplished.jpg", 74 | } 75 | ], 76 | "mentions": [], 77 | "tags": [], 78 | "emojis": [], 79 | "card": None, 80 | "poll": None, 81 | } 82 | 83 | # Turn dict into object needed by mastodon.py 84 | # Use this in tests after making any changes 85 | # you need to your dict object 86 | # NOTE: ensure values in the dict object are what you need: 87 | # it can be mutated by any test before your test runs 88 | 89 | 90 | def dict2obj(d): 91 | # checking whether object d is a 92 | # instance of class list 93 | if isinstance(d, list): 94 | d = [dict2obj(x) for x in d] 95 | 96 | # if d is not a instance of dict then 97 | # directly object is returned 98 | if not isinstance(d, dict): 99 | return d 100 | 101 | # declaring a class 102 | class C: 103 | pass 104 | 105 | # constructor of the class passed to obj 106 | obj = C() 107 | 108 | for k in d: 109 | obj.__dict__[k] = dict2obj(d[k]) 110 | 111 | return obj 112 | 113 | 114 | # here is our toot object - use this in tests 115 | toot = dict2obj(toot_dict) 116 | 117 | # config file after being parsed by yaml.safe_load 118 | config_file = { 119 | "access_token": "abcd_1234", 120 | "username": "alice", 121 | "base_url": "test.social", 122 | "hashtags_to_keep": ["ephemetoot"], 123 | "days_to_keep": 14, 124 | "keep_pinned": True, 125 | "toots_to_keep": [103996285277439262, 103976473612749097, 103877521458738491], 126 | "visibility_to_keep": [], 127 | "archive": "archive", 128 | "archive_media": False, 129 | } 130 | 131 | 132 | # mock GitHub API call for the version number 133 | class MockGitHub: 134 | @staticmethod 135 | def json(): 136 | return {"tag_name": "vLATEST_VERSION"} 137 | 138 | 139 | # mock image call for archive 140 | class MockMedia: 141 | f = open("tests/accomplished.jpg", "rb") 142 | content = f.read() 143 | f.close() 144 | 145 | 146 | # mock Mastodon 147 | class Mocktodon: 148 | def __init__(self): 149 | return None 150 | 151 | def status_delete(self, t=toot): 152 | return None 153 | 154 | def status_unreblog(self, t=toot): 155 | return None 156 | 157 | def ratelimit_remaining(self): 158 | return 100 159 | 160 | def account_statuses(self, user_id=None, limit=None, max_id=None): 161 | # create 10 statuses 162 | # the first 2 will be checked in the first batch (in production it would be 40) 163 | user_toots = [] 164 | 165 | def make_toot(i=1): 166 | if i < 11: 167 | keys = ("id", "created_at", "reblog", "tags", "visibility") 168 | vals = ( 169 | i, 170 | datetime.datetime(2018, 11, i, 23, 15, 15, 718000, tzinfo=tzutc()), 171 | False, 172 | [], 173 | "public", 174 | ) 175 | user_toot = dict(zip(keys, vals)) 176 | user_toots.append(user_toot) 177 | total = i + 1 178 | make_toot(total) 179 | 180 | user_toots.sort(reverse=True) 181 | make_toot(1) # make the batch of toots 182 | # ignore user_id 183 | # filter for toots with id smaller than max_id 184 | this_batch = [] 185 | # use dict_to_obj to make a toot for each toot in the obj then a list from that 186 | this_batch = [dict2obj(t) for t in user_toots if t["id"] > max_id][:limit] 187 | return this_batch 188 | 189 | 190 | # mock argparse objects (options) 191 | class Namespace: 192 | def __init__( 193 | self, 194 | archive_deleted=False, 195 | datestamp=False, 196 | hide_skipped=False, 197 | retry_mins=1, 198 | schedule=False, 199 | test=False, 200 | time=False, 201 | quiet=False, 202 | ): 203 | self.archive_deleted = archive_deleted 204 | self.datestamp = datestamp 205 | self.schedule = schedule 206 | self.time = time 207 | self.test = test 208 | self.hide_skipped = hide_skipped 209 | self.quiet = quiet 210 | self.retry_mins = retry_mins 211 | 212 | 213 | @pytest.fixture 214 | def mock_github_response(monkeypatch): 215 | def mock_get(*args, **kwargs): 216 | return MockGitHub() 217 | 218 | monkeypatch.setattr(requests, "get", mock_get) 219 | 220 | 221 | @pytest.fixture 222 | def mock_archive_response(monkeypatch): 223 | def mock_get(*args, **kwargs): 224 | return MockMedia() 225 | 226 | monkeypatch.setattr(requests, "get", mock_get) 227 | 228 | 229 | ######################## 230 | # TESTS # 231 | ######################## 232 | 233 | # Tests should be listed in alphabetical order 234 | # Remember that a previous test may have mutated 235 | # one of the values above: set all values you are using 236 | 237 | 238 | def test_archive_toot(tmpdir): 239 | p = tmpdir.mkdir("archive") 240 | config_file["archive"] = str(p) # make archive directory a temp test dir 241 | 242 | ephemetoot.archive_toot(config_file, toot) 243 | 244 | file_exists = os.path.exists(p + "/104136090490756999.json") 245 | assert file_exists 246 | 247 | 248 | def test_archive_toot_media(mock_archive_response, tmpdir): 249 | p = tmpdir.mkdir("archive") 250 | config_file["archive"] = str(p) # make archive directory a temp test dir 251 | config_file["archive_media"] = True 252 | ephemetoot.archive_toot_media(p, toot.media_attachments[0].url) 253 | image_exists = os.path.exists(p + "/hugh.run/success/accomplished.jpg") 254 | config_file["archive_media"] = False 255 | assert image_exists 256 | 257 | 258 | def test_check_batch(capfd, monkeypatch): 259 | config = config_file 260 | options = Namespace(archive_deleted=False) 261 | mastodon = Mocktodon() 262 | user_id = "test_user_id" 263 | # limit to 2 so check_batch calls itself for the last 8 toots 264 | timeline = mastodon.account_statuses(user_id=user_id, limit=2, max_id=0) 265 | # monkeypatch process_toot to add 1 to deleted_count and return 266 | # this simulates what would happen if the toot was being deleted 267 | monkeypatch.setattr( 268 | "ephemetoot.ephemetoot.process_toot", 269 | lambda config, options, mastodon, toot, deleted_count: deleted_count + 1, 270 | ) 271 | # run check_batch 272 | ephemetoot.check_batch(config, options, mastodon, user_id, timeline, 0) 273 | # deleted_count should be 10 274 | output = capfd.readouterr().out.split("\n") 275 | assert output[0] == "Removed 10 toots for alice@test.social." 276 | 277 | 278 | def test_check_batch_quiet(capfd, monkeypatch): 279 | config = config_file 280 | options = Namespace(archive_deleted=False, quiet=1) 281 | mastodon = Mocktodon() 282 | user_id = "test_user_id" 283 | timeline = mastodon.account_statuses(user_id=user_id, limit=2, max_id=0) 284 | monkeypatch.setattr( 285 | "ephemetoot.ephemetoot.process_toot", 286 | lambda config, options, mastodon, toot, deleted_count: deleted_count + 1, 287 | ) 288 | ephemetoot.check_batch(config, options, mastodon, user_id, timeline, 0) 289 | # deleted_count should be 10 290 | output = capfd.readouterr().out.split("\n") 291 | assert output[0] == "Removed 10 toots for alice@test.social." 292 | 293 | 294 | def test_check_batch_quiet_no_toots(capfd, monkeypatch): 295 | config = config_file 296 | options = Namespace(archive_deleted=False, quiet=2) 297 | mastodon = Mocktodon() 298 | user_id = "test_user_id" 299 | # max_id is the last toot in our batch so this returns no toots 300 | timeline = mastodon.account_statuses(user_id=user_id, limit=2, max_id=10) 301 | monkeypatch.setattr( 302 | "ephemetoot.ephemetoot.process_toot", 303 | lambda config, options, mastodon, toot, deleted_count: deleted_count + 1, 304 | ) 305 | # run check_batch 306 | ephemetoot.check_batch(config, options, mastodon, user_id, timeline, 0) 307 | # deleted_count should be 0 but with quiet=2 there should be not output 308 | output = capfd.readouterr().out 309 | assert output == "" 310 | 311 | 312 | def test_check_batch_qq(capfd, monkeypatch): 313 | config = config_file 314 | options = Namespace(archive_deleted=False, quiet=2) 315 | mastodon = Mocktodon() 316 | user_id = "test_user_id" 317 | timeline = mastodon.account_statuses(user_id=user_id, limit=2, max_id=0) 318 | monkeypatch.setattr( 319 | "ephemetoot.ephemetoot.process_toot", 320 | lambda config, options, mastodon, toot, deleted_count: deleted_count + 1, 321 | ) 322 | ephemetoot.check_batch(config, options, mastodon, user_id, timeline, 0) 323 | # deleted_count should be 10 and message printed since there was a delete 324 | output = capfd.readouterr().out.split("\n") 325 | assert output[0] == "Removed 10 toots for alice@test.social." 326 | 327 | 328 | def test_check_batch_qq_no_deletes(capfd, monkeypatch): 329 | config = config_file 330 | options = Namespace(archive_deleted=False, quiet=2) 331 | mastodon = Mocktodon() 332 | user_id = "quiet_user_id" 333 | timeline = mastodon.account_statuses(user_id=user_id, limit=2, max_id=0) 334 | # simulate no deletes occuring 335 | monkeypatch.setattr( 336 | "ephemetoot.ephemetoot.process_toot", 337 | lambda config, options, mastodon, toot, deleted_count: 0, 338 | ) 339 | # run check_batch 340 | ephemetoot.check_batch(config, options, mastodon, user_id, timeline, 0) 341 | # deleted_count should be 0 with no message since quiet=2 342 | output = capfd.readouterr().out 343 | assert output == "" 344 | 345 | 346 | def test_check_batch_qqq(capfd, monkeypatch): 347 | config = config_file 348 | options = Namespace(archive_deleted=False, quiet=3) 349 | mastodon = Mocktodon() 350 | user_id = "test_user_id" 351 | timeline = mastodon.account_statuses(user_id=user_id, limit=2, max_id=0) 352 | monkeypatch.setattr( 353 | "ephemetoot.ephemetoot.process_toot", 354 | lambda config, options, mastodon, toot, deleted_count: deleted_count + 1, 355 | ) 356 | # run check_batch 357 | ephemetoot.check_batch(config, options, mastodon, user_id, timeline, 0) 358 | # deleted_count should be 10 and no message should be printed since quiet=3 359 | output = capfd.readouterr().out 360 | assert output == "" 361 | 362 | 363 | def test_console_print(capfd): 364 | ephemetoot.console_print( 365 | "test123", Namespace(test=False, hide_skipped=False, quiet=False), False 366 | ) 367 | assert capfd.readouterr().out == "test123\n" 368 | 369 | 370 | def test_console_print_quiet(): 371 | result = ephemetoot.console_print( 372 | "test123", Namespace(test=False, hide_skipped=False, quiet=True), False 373 | ) 374 | assert result == None 375 | 376 | 377 | def test_console_print_skip(): 378 | result = ephemetoot.console_print( 379 | "test123", Namespace(test=False, hide_skipped=True, quiet=False), True 380 | ) 381 | assert result == None 382 | 383 | 384 | def test_datestamp_now(): 385 | datestamp = ephemetoot.datestamp_now() 386 | date_object = datetime.datetime.strptime(datestamp, "%a %d %b %Y %H:%M:%S %z") 387 | # use timetuple() to exclude differences in milliseconds 388 | assert datetime.datetime.now(timezone.utc).timetuple() == date_object.timetuple() 389 | 390 | 391 | def test_init(monkeypatch, tmpdir): 392 | 393 | # monkeypatch current directory 394 | current_dir = tmpdir.mkdir("current_dir") # temporary directory for testing 395 | monkeypatch.chdir(current_dir) 396 | 397 | # monkeypatch input ...outputs 398 | monkeypatch.setattr( 399 | "ephemetoot.ephemetoot.compulsory_input", lambda a, b, c: "compulsory" 400 | ) 401 | monkeypatch.setattr("ephemetoot.ephemetoot.digit_input", lambda a, b, c: "14") 402 | monkeypatch.setattr("ephemetoot.ephemetoot.yes_no_input", lambda a, b: "false") 403 | monkeypatch.setattr( 404 | "ephemetoot.ephemetoot.optional_input", lambda a, b, c: "optional" 405 | ) 406 | 407 | # run init 408 | ephemetoot.init() 409 | assert os.path.exists(os.path.join(current_dir, "config.yaml")) 410 | 411 | 412 | def test_init_archive_path(tmpdir): 413 | 414 | good_path = tmpdir.mkdir("archive_dir") # temporary directory for testing 415 | wrong = ephemetoot.sanitise_input( 416 | os.path.join(good_path, "/bad/path/"), "Archive path", None 417 | ) 418 | ok = ephemetoot.sanitise_input(good_path, "Archive path", None) 419 | 420 | assert ok == "ok" 421 | assert wrong == "That directory does not exist, please try again" 422 | 423 | 424 | def test_init_sanitise_id_list(): 425 | tags = ("\033[96m", "\033[2m", "\033[0m") 426 | wrong = ephemetoot.sanitise_input( 427 | "987654321, toot_id_number", "Toots to keep", tags 428 | ) 429 | also_wrong = ephemetoot.sanitise_input("toot_id_number", "Toots to keep", tags) 430 | ok = ephemetoot.sanitise_input("1234598745, 999933335555", "Toots to keep", tags) 431 | also_ok = ephemetoot.sanitise_input("1234598745", "Toots to keep", tags) 432 | 433 | assert wrong == "Toot IDs must be numeric and separated with commas" 434 | assert also_wrong == "Toot IDs must be numeric and separated with commas" 435 | assert ok == "ok" 436 | assert also_ok == "ok" 437 | 438 | 439 | def test_init_sanitise_tag_list(): 440 | tags = ("\033[96m", "\033[2m", "\033[0m") 441 | wrong = ephemetoot.sanitise_input("#tag, another_tag", "Hashtags to keep", tags) 442 | also_wrong = ephemetoot.sanitise_input("tag, another tag", "Hashtags to keep", tags) 443 | still_wrong = ephemetoot.sanitise_input("tag, 12345", "Hashtags to keep", tags) 444 | ok = ephemetoot.sanitise_input("tag123, another_TAG", "Hashtags to keep", tags) 445 | also_ok = ephemetoot.sanitise_input("single_tag", "Hashtags to keep", tags) 446 | 447 | error = ( 448 | "Hashtags must not include '#' and must match rules at " 449 | + tags[0] 450 | + "https://docs.joinmastodon.org/user/posting/#hashtags" 451 | + tags[2] 452 | ) 453 | 454 | assert ok == "ok" 455 | assert also_ok == "ok" 456 | assert wrong == error 457 | assert also_wrong == error 458 | assert still_wrong == error 459 | 460 | 461 | def test_init_sanitise_url(): 462 | tags = ("\033[96m", "\033[2m", "\033[0m") 463 | wrong = ephemetoot.sanitise_input("http://example.social", "Base URL", tags) 464 | ok = ephemetoot.sanitise_input("example.social", "Base URL", tags) 465 | 466 | assert ( 467 | wrong 468 | == "Provide full domain without protocol prefix (e.g. \033[2mexample.social\033[0m, not \033[2mhttp://example.social\033[0m)" 469 | ) 470 | assert ok == "ok" 471 | 472 | 473 | def test_init_sanitise_username(): 474 | tags = ("\033[96m", "\033[2m", "\033[0m") 475 | wrong = ephemetoot.sanitise_input("@alice", "Username", tags) 476 | ok = ephemetoot.sanitise_input("alice", "Username", tags) 477 | 478 | assert wrong == "Do not include '@' in username, please try again" 479 | assert ok == "ok" 480 | 481 | 482 | def test_init_sanitise_visibility_list(): 483 | tags = ("\033[96m", "\033[2m", "\033[0m") 484 | wrong = ephemetoot.sanitise_input("nonexistent", "Visibility to keep", tags) 485 | also_wrong = ephemetoot.sanitise_input("direct public", "Visibility to keep", tags) 486 | ok = ephemetoot.sanitise_input("direct", "Visibility to keep", tags) 487 | also_ok = ephemetoot.sanitise_input("direct, public", "Visibility to keep", tags) 488 | 489 | error = ( 490 | "Valid values are one or more of 'public', 'unlisted', 'private' or 'direct'" 491 | ) 492 | assert ok == "ok" 493 | assert also_ok == "ok" 494 | assert wrong == error 495 | assert also_wrong == error 496 | 497 | 498 | def test_jsondefault(): 499 | d = ephemetoot.jsondefault(toot.created_at) 500 | assert d == "2020-05-09T02:17:18.598000+00:00" 501 | 502 | 503 | def test_process_toot(capfd, tmpdir, monkeypatch): 504 | # config uses config_listed at top of this tests file 505 | p = tmpdir.mkdir("archive") # use temporary test directory 506 | config_file["archive"] = str(p) 507 | config_file["keep_pinned"] = False 508 | config_file["toots_to_keep"] = [] 509 | config_file["visibility_to_keep"] = [] 510 | options = Namespace(archive_deleted=False) 511 | mastodon = Mocktodon() 512 | toot_dict["pinned"] = False 513 | toot_dict["visibility"] = "public" 514 | toot_dict["reblog"] = False 515 | toot = dict2obj(toot_dict) 516 | ephemetoot.process_toot(config_file, options, mastodon, toot, 0) 517 | assert ( 518 | capfd.readouterr().out 519 | == "❌ deleting toot 104136090490756999 tooted 09 May 2020\n" 520 | ) 521 | 522 | 523 | def test_process_toot_pinned(capfd, tmpdir): 524 | # config uses config_listed at top of this tests file 525 | p = tmpdir.mkdir("archive") # use temporary test directory 526 | config_file["archive"] = str(p) 527 | config_file["keep_pinned"] = True 528 | options = Namespace(archive_deleted=False) 529 | mastodon = Mocktodon() 530 | toot_dict["pinned"] = True 531 | toot = dict2obj(toot_dict) 532 | ephemetoot.process_toot(config_file, options, mastodon, toot, 0) 533 | assert capfd.readouterr().out == "📌 skipping pinned toot - 104136090490756999\n" 534 | 535 | 536 | def test_process_toot_saved(capfd, tmpdir): 537 | # config uses config_listed at top of this tests file 538 | p = tmpdir.mkdir("archive") # use temporary test directory 539 | config_file["archive"] = str(p) 540 | config_file["keep_pinned"] = False 541 | config_file["toots_to_keep"].append(104136090490756999) 542 | options = Namespace(archive_deleted=False) 543 | mastodon = Mocktodon() 544 | toot_dict["pinned"] = False 545 | toot = dict2obj(toot_dict) 546 | ephemetoot.process_toot(config_file, options, mastodon, toot, 0) 547 | assert capfd.readouterr().out == "💾 skipping saved toot - 104136090490756999\n" 548 | 549 | 550 | def test_process_toot_visibility(capfd, tmpdir): 551 | # config uses config_listed at top of this tests file 552 | p = tmpdir.mkdir("archive") # use temporary test directory 553 | config_file["archive"] = str(p) 554 | config_file["keep_pinned"] = False # is true above so make false 555 | config_file["toots_to_keep"].remove(104136090490756999) # don't keep this toot 556 | config_file["visibility_to_keep"].append("testing") 557 | options = Namespace(archive_deleted=False) 558 | mastodon = Mocktodon() 559 | toot_dict["pinned"] = False # is true above so make false 560 | toot_dict["visibility"] = "testing" 561 | toot = dict2obj(toot_dict) 562 | ephemetoot.process_toot(config_file, options, mastodon, toot, 0) 563 | assert capfd.readouterr().out == "👀 skipping testing toot - 104136090490756999\n" 564 | 565 | 566 | def test_process_toot_hashtag(capfd, tmpdir, monkeypatch): 567 | # config uses config_listed at top of this tests file 568 | p = tmpdir.mkdir("archive") # use temporary test directory 569 | config_file["archive"] = str(p) 570 | config_file["keep_pinned"] = False 571 | config_file["toots_to_keep"] = [] 572 | config_file["visibility_to_keep"] = [] 573 | options = Namespace(archive_deleted=False) 574 | mastodon = Mocktodon() 575 | toot_dict["pinned"] = False 576 | toot_dict["visibility"] = "public" 577 | toot_dict["reblog"] = True 578 | toot = dict2obj(toot_dict) 579 | 580 | ephemetoot.process_toot(config_file, options, mastodon, toot, 0) 581 | assert ( 582 | capfd.readouterr().out 583 | == "👎 unboosting toot 104136090490756999 boosted 09 May 2020\n" 584 | ) 585 | 586 | 587 | def test_retry_on_error(): 588 | # Namespace object constructed from top of tests (representing options) 589 | # toot comes from variable at top of test 590 | mastodon = Mocktodon() 591 | toot = dict2obj(toot_dict) 592 | retry = ephemetoot.retry_on_error(Namespace(retry_mins=True), mastodon, toot, 5) 593 | assert retry == None # should not return an error 594 | 595 | 596 | def test_retry_on_error_max_tries(): 597 | # Namespace object constructed from top of tests (representing options) 598 | # toot and mastodon come from objects at top of test 599 | with pytest.raises(TimeoutError): 600 | mastodon = Mocktodon() 601 | toot = dict2obj(toot_dict) 602 | retry = ephemetoot.retry_on_error(Namespace(retry_mins=True), mastodon, toot, 7) 603 | 604 | 605 | def test_schedule(monkeypatch, tmpdir): 606 | 607 | home = tmpdir.mkdir("current_dir") # temporary directory for testing 608 | launch = tmpdir.mkdir("TestAgents") # temporary directory for testing 609 | 610 | # monkeypatch directories and suppress the plist loading process 611 | # NOTE: it may be possible to test the plist loading process 612 | # but I can't work out how to do it universally / consistently 613 | 614 | def mock_current_dir(): 615 | return str(home) 616 | 617 | def mock_home_dir_expansion(arg): 618 | return str(launch) 619 | 620 | def suppress_subprocess(args, stdout=None, stderr=None, shell=None): 621 | return None 622 | 623 | monkeypatch.setattr(os, "getcwd", mock_current_dir) 624 | monkeypatch.setattr(os.path, "expanduser", mock_home_dir_expansion) 625 | monkeypatch.setattr(subprocess, "run", suppress_subprocess) 626 | 627 | # now we run the function we're testing 628 | ephemetoot.schedule(Namespace(schedule=".", time=None)) 629 | 630 | # assert the plist file was created 631 | plist_file = os.path.join(launch, "ephemetoot.scheduler.plist") 632 | assert os.path.lexists(plist_file) 633 | 634 | # check that correct values were modified in the file 635 | f = open(plist_file, "r") 636 | plist = f.readlines() 637 | assert plist[7] == " " + str(home) + "\n" 638 | assert plist[7] == " " + str(home) + "\n" 639 | assert plist[10] == " " + sys.argv[0] + "\n" 640 | assert plist[12] == " " + str(home) + "/config.yaml\n" 641 | assert plist[15] == " " + str(home) + "/ephemetoot.log\n" 642 | assert plist[17] == " " + str(home) + "/ephemetoot.error.log\n" 643 | 644 | 645 | def test_schedule_with_time(monkeypatch, tmpdir): 646 | 647 | home = tmpdir.mkdir("current_dir") # temporary directory for testing 648 | launch = tmpdir.mkdir("TestAgents") # temporary directory for testing 649 | 650 | # monkeypatch directories and suppress the plist loading process 651 | # NOTE: it may be possible to test the plist loading process 652 | # but I can't work out how to do it universally / consistently 653 | 654 | def mock_current_dir(): 655 | return str(home) 656 | 657 | def mock_home_dir_expansion(arg): 658 | return str(launch) 659 | 660 | def suppress_subprocess(args, stdout=None, stderr=None, shell=None): 661 | return None 662 | 663 | monkeypatch.setattr(os, "getcwd", mock_current_dir) 664 | monkeypatch.setattr(os.path, "expanduser", mock_home_dir_expansion) 665 | monkeypatch.setattr(subprocess, "run", suppress_subprocess) 666 | 667 | # now we run the function we're testing 668 | ephemetoot.schedule(Namespace(schedule=".", time=["10", "30"])) 669 | 670 | # assert the plist file was created 671 | plist_file = os.path.join(launch, "ephemetoot.scheduler.plist") 672 | assert os.path.lexists(plist_file) 673 | 674 | # assert that correct values were modified in the file 675 | f = open(plist_file, "r") 676 | plist = f.readlines() 677 | 678 | assert plist[21] == " 10\n" 679 | assert plist[23] == " 30\n" 680 | 681 | 682 | def test_tooted_date(): 683 | string = ephemetoot.tooted_date(toot) 684 | created = datetime.datetime(2020, 5, 9, 2, 17, 18, 598000, tzinfo=timezone.utc) 685 | test_string = created.strftime("%d %b %Y") 686 | 687 | assert string == test_string 688 | 689 | 690 | def test_version(mock_github_response, capfd): 691 | ephemetoot.version("TEST_VERSION") 692 | output = capfd.readouterr().out 693 | msg = """ 694 | ephemetoot ==> 🥳 ==> 🧼 ==> 😇 695 | ------------------------------- 696 | You are using release: \033[92mvTEST_VERSION\033[0m 697 | The latest release is: \033[92mvLATEST_VERSION\033[0m 698 | To upgrade to the most recent version run \033[92mpip install --upgrade ephemetoot\033[0m\n""" 699 | 700 | assert output == msg 701 | --------------------------------------------------------------------------------