mailmanclient-3.2.2/0000775000175000017500000000000013427703737015470 5ustar maxkingmaxking00000000000000mailmanclient-3.2.2/COPYING.LESSER0000664000175000017500000001672513413656045017524 0ustar maxkingmaxking00000000000000 GNU LESSER GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. This version of the GNU Lesser General Public License incorporates the terms and conditions of version 3 of the GNU General Public License, supplemented by the additional permissions listed below. 0. Additional Definitions. As used herein, "this License" refers to version 3 of the GNU Lesser General Public License, and the "GNU GPL" refers to version 3 of the GNU General Public License. "The Library" refers to a covered work governed by this License, other than an Application or a Combined Work as defined below. An "Application" is any work that makes use of an interface provided by the Library, but which is not otherwise based on the Library. Defining a subclass of a class defined by the Library is deemed a mode of using an interface provided by the Library. A "Combined Work" is a work produced by combining or linking an Application with the Library. The particular version of the Library with which the Combined Work was made is also called the "Linked Version". The "Minimal Corresponding Source" for a Combined Work means the Corresponding Source for the Combined Work, excluding any source code for portions of the Combined Work that, considered in isolation, are based on the Application, and not on the Linked Version. The "Corresponding Application Code" for a Combined Work means the object code and/or source code for the Application, including any data and utility programs needed for reproducing the Combined Work from the Application, but excluding the System Libraries of the Combined Work. 1. Exception to Section 3 of the GNU GPL. You may convey a covered work under sections 3 and 4 of this License without being bound by section 3 of the GNU GPL. 2. Conveying Modified Versions. If you modify a copy of the Library, and, in your modifications, a facility refers to a function or data to be supplied by an Application that uses the facility (other than as an argument passed when the facility is invoked), then you may convey a copy of the modified version: a) under this License, provided that you make a good faith effort to ensure that, in the event an Application does not supply the function or data, the facility still operates, and performs whatever part of its purpose remains meaningful, or b) under the GNU GPL, with none of the additional permissions of this License applicable to that copy. 3. Object Code Incorporating Material from Library Header Files. The object code form of an Application may incorporate material from a header file that is part of the Library. You may convey such object code under terms of your choice, provided that, if the incorporated material is not limited to numerical parameters, data structure layouts and accessors, or small macros, inline functions and templates (ten or fewer lines in length), you do both of the following: a) Give prominent notice with each copy of the object code that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the object code with a copy of the GNU GPL and this license document. 4. Combined Works. You may convey a Combined Work under terms of your choice that, taken together, effectively do not restrict modification of the portions of the Library contained in the Combined Work and reverse engineering for debugging such modifications, if you also do each of the following: a) Give prominent notice with each copy of the Combined Work that the Library is used in it and that the Library and its use are covered by this License. b) Accompany the Combined Work with a copy of the GNU GPL and this license document. c) For a Combined Work that displays copyright notices during execution, include the copyright notice for the Library among these notices, as well as a reference directing the user to the copies of the GNU GPL and this license document. d) Do one of the following: 0) Convey the Minimal Corresponding Source under the terms of this License, and the Corresponding Application Code in a form suitable for, and under terms that permit, the user to recombine or relink the Application with a modified version of the Linked Version to produce a modified Combined Work, in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source. 1) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (a) uses at run time a copy of the Library already present on the user's computer system, and (b) will operate properly with a modified version of the Library that is interface-compatible with the Linked Version. e) Provide Installation Information, but only if you would otherwise be required to provide such information under section 6 of the GNU GPL, and only to the extent that such information is necessary to install and execute a modified version of the Combined Work produced by recombining or relinking the Application with a modified version of the Linked Version. (If you use option 4d0, the Installation Information must accompany the Minimal Corresponding Source and Corresponding Application Code. If you use option 4d1, you must provide the Installation Information in the manner specified by section 6 of the GNU GPL for conveying Corresponding Source.) 5. Combined Libraries. You may place library facilities that are a work based on the Library side by side in a single library together with other library facilities that are not Applications and are not covered by this License, and convey such a combined library under terms of your choice, if you do both of the following: a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities, conveyed under the terms of this License. b) Give prominent notice with the combined library that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. 6. Revised Versions of the GNU Lesser General Public License. The Free Software Foundation may publish revised and/or new versions of the GNU Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Library as you received it specifies that a certain numbered version of the GNU Lesser General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that published version or of any later version published by the Free Software Foundation. If the Library as you received it does not specify a version number of the GNU Lesser General Public License, you may choose any version of the GNU Lesser General Public License ever published by the Free Software Foundation. If the Library as you received it specifies that a proxy can decide whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the Library. mailmanclient-3.2.2/MANIFEST.in0000664000175000017500000000023513413656045017220 0ustar maxkingmaxking00000000000000include *.py MANIFEST.in *.cfg *.ini COPYING.LESSER global-include *.txt *.rst *.yaml include Makefile prune _build prune dist prune .tox exclude .bzrignore mailmanclient-3.2.2/Makefile0000664000175000017500000000603513413656045017126 0ustar maxkingmaxking00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d _build/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: -rm -rf _build/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) _build/html @echo @echo "Build finished. The HTML pages are in _build/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) _build/dirhtml @echo @echo "Build finished. The HTML pages are in _build/dirhtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) _build/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) _build/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) _build/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in _build/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) _build/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in _build/qthelp, like this:" @echo "# qcollectiongenerator _build/qthelp/munepy.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile _build/qthelp/munepy.qhc" latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) _build/latex @echo @echo "Build finished; the LaTeX files are in _build/latex." @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ "run these through (pdf)latex." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) _build/changes @echo @echo "The overview file is in _build/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) _build/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in _build/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) _build/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in _build/doctest/output.txt." pypi: html (cd _build/html; \ rm -f index.html; \ ln -s README.html index.html; \ zip -r ../pypi .) mailmanclient-3.2.2/PKG-INFO0000664000175000017500000001001413427703737016561 0ustar maxkingmaxking00000000000000Metadata-Version: 2.1 Name: mailmanclient Version: 3.2.2 Summary: mailmanclient -- Python bindings for Mailman REST API Home-page: http://www.list.org/ Maintainer: Barry Warsaw Maintainer-email: barry@list.org License: LGPLv3 Description: .. This file is part of mailmanclient. mailmanclient is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation, version 3 of the License. mailmanclient is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with mailman.client. If not, see . ============== Mailman Client ============== .. image:: https://gitlab.com/mailman/mailmanclient/badges/master/build.svg :target: https://gitlab.com/mailman/mailmanclient/commits/master .. image:: https://readthedocs.org/projects/mailmanclient/badge :target: https://mailmanclient.readthedocs.io .. image:: http://img.shields.io/pypi/v/mailmanclient.svg :target: https://pypi.python.org/pypi/mailmanclient .. image:: http://img.shields.io/pypi/dm/mailmanclient.svg :target: https://pypi.python.org/pypi/mailmanclient The ``mailmanclient`` library provides official Python bindings for the GNU Mailman 3 REST API. Requirements ============ ``mailmanclient`` requires Python 3.5 or newer. Documentation ============= A `simple guide`_ to using the library is available within this package, in the form of doctests. The manual is also available online at: https://mailmanclient.readthedocs.io/en/latest/ Project details =============== The project home page is: https://gitlab.com/mailman/mailmanclient You should report bugs at: https://gitlab.com/mailman/mailmanclient/issues You can download the latest version of the package either from the `Cheese Shop`_: http://pypi.python.org/pypi/mailmanclient or from the GitLab page above. Of course you can also just install it with ``pip`` from the command line:: $ pip install mailmanclient You can grab the latest development copy of the code using Git, from the Gitlab home page above. If you have Git installed, you can grab your own branch of the code like this:: $ git clone https://gitlab.com/mailman/mailmanclient.git You may contact the developers via mailman-developers@python.org Acknowledgements ================ Many thanks to Florian Fuchs for his contribution of an initial REST client. Also thanks to all the contributors of Mailman Client who have contributed code, raised issues or devoted their time in any capacity! .. _`simple guide`: https://mailmanclient.readthedocs.io/en/latest/src/mailmanclient/docs/using.html .. _`Cheese Shop`: https://pypi.org/project/mailmanclient Platform: UNKNOWN Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+) Classifier: Operating System :: POSIX Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: 3.7 Classifier: Topic :: Internet :: WWW/HTTP Description-Content-Type: text/x-rst mailmanclient-3.2.2/README.rst0000664000175000017500000000533713427702377017166 0ustar maxkingmaxking00000000000000.. This file is part of mailmanclient. mailmanclient is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation, version 3 of the License. mailmanclient is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with mailman.client. If not, see . ============== Mailman Client ============== .. image:: https://gitlab.com/mailman/mailmanclient/badges/master/build.svg :target: https://gitlab.com/mailman/mailmanclient/commits/master .. image:: https://readthedocs.org/projects/mailmanclient/badge :target: https://mailmanclient.readthedocs.io .. image:: http://img.shields.io/pypi/v/mailmanclient.svg :target: https://pypi.python.org/pypi/mailmanclient .. image:: http://img.shields.io/pypi/dm/mailmanclient.svg :target: https://pypi.python.org/pypi/mailmanclient The ``mailmanclient`` library provides official Python bindings for the GNU Mailman 3 REST API. Requirements ============ ``mailmanclient`` requires Python 3.5 or newer. Documentation ============= A `simple guide`_ to using the library is available within this package, in the form of doctests. The manual is also available online at: https://mailmanclient.readthedocs.io/en/latest/ Project details =============== The project home page is: https://gitlab.com/mailman/mailmanclient You should report bugs at: https://gitlab.com/mailman/mailmanclient/issues You can download the latest version of the package either from the `Cheese Shop`_: http://pypi.python.org/pypi/mailmanclient or from the GitLab page above. Of course you can also just install it with ``pip`` from the command line:: $ pip install mailmanclient You can grab the latest development copy of the code using Git, from the Gitlab home page above. If you have Git installed, you can grab your own branch of the code like this:: $ git clone https://gitlab.com/mailman/mailmanclient.git You may contact the developers via mailman-developers@python.org Acknowledgements ================ Many thanks to Florian Fuchs for his contribution of an initial REST client. Also thanks to all the contributors of Mailman Client who have contributed code, raised issues or devoted their time in any capacity! .. _`simple guide`: https://mailmanclient.readthedocs.io/en/latest/src/mailmanclient/docs/using.html .. _`Cheese Shop`: https://pypi.org/project/mailmanclient mailmanclient-3.2.2/conf.py0000664000175000017500000001647613427702377017004 0ustar maxkingmaxking00000000000000# -*- coding: utf-8 -*- # # mailman.client documentation build configuration file, created by # sphinx-quickstart on Wed Aug 17 15:43:10 2011. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import sys import os # import the source code directory into Python Path for use with Auto Module APP_ROOT = os.path.dirname(__file__) sys.path.insert(0, APP_ROOT) # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here # sys.path.insert(0, os.path.abspath('.')) # -- General configuration ---------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] # Add any paths that contain templates here, relative to this directory. # templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = u'Mailman Client' copyright = u'2012-2019, The Free Software Foundation' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. from setup_helpers import get_version # noqa _version = get_version('src/mailmanclient/constants.py') version = '.'.join(_version.split('.')[0:2]) # The full version, including alpha/beta/rc tags. release = _version # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # today = '' # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ['_build', '.tox', 'eggs', '.pc'] # The reST default role (used for this markup: `text`) to use for all # documents. # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # -- Options for HTML output -------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'default' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". # html_title = None # A shorter title for the navigation bar. Default is the same as html_title. # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". # html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. # html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. # html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. # html_additional_pages = {} # If false, no module index is generated. # html_domain_indices = True # If false, no index is generated. # html_use_index = True # If true, the index is split into individual pages for each letter. # html_split_index = False # If true, links to the reST sources are added to the pages. # html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'mailmanclientdoc' # -- Options for LaTeX output ------------------------------------------------- # The paper size ('letter' or 'a4'). # latex_paper_size = 'letter' # The font size ('10pt', '11pt' or '12pt'). # latex_font_size = '10pt' # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]) latex_documents = [ ('README', 'mailmanclient.tex', u'Mailman Client Documentation', u'Mailman Coders', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # latex_use_parts = False # If true, show page references after internal links. # latex_show_pagerefs = False # If true, show URL addresses after external links. # latex_show_urls = False # Additional stuff for the LaTeX preamble. # latex_preamble = '' # Documents to append as an appendix to all manuals. # latex_appendices = [] # If false, no module index is generated. # latex_domain_indices = True # -- Options for manual page output ------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('README', 'mailmanclient', u'Mailman Client Documentation', [u'Mailman Coders'], 1) ] mailmanclient-3.2.2/conftest.py0000664000175000017500000000164613413656572017675 0ustar maxkingmaxking00000000000000# Copyright (C) 2017-2019 by the Free Software Foundation, Inc. # # This file is part of mailman.client. # # mailman.client is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailman.client is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailman.client. If not, see . """pytest conftest""" __metaclass__ = type import os import pytest @pytest.fixture def vcr_cassette_path(request, vcr_cassette_name): return os.path.join('src/mailmanclient/tests/data', vcr_cassette_name) mailmanclient-3.2.2/copybump.py0000775000175000017500000000534213413656572017706 0ustar maxkingmaxking00000000000000#! /usr/bin/env python3 import os import re import sys import stat import datetime FSF = 'by the Free Software Foundation, Inc.' this_year = datetime.date.today().year pyre_c = re.compile(r'# Copyright \(C\) ((?P\d{4})-)?(?P\d{4})') pyre_n = re.compile(r'# Copyright ((?P\d{4})-)?(?P\d{4})') new_c = '# Copyright (C) {}-{} {}' new_n = '# Copyright {}-{} {}' MODE = (stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO) if '--noc' in sys.argv: pyre = pyre_n new = new_n sys.argv.remove('--noc') else: pyre = pyre_c new = new_c def do_file(path, owner): permissions = os.stat(path).st_mode & MODE with open(path) as in_file, open(path + '.out', 'w') as out_file: try: for line in in_file: mo_c = pyre_c.match(line) mo_n = pyre_n.match(line) if mo_c is None and mo_n is None: out_file.write(line) continue mo = (mo_n if mo_c is None else mo_c) start = (mo.group('end') if mo.group('start') is None else mo.group('start')) if int(start) == this_year: out_file.write(line) continue print(new.format(start, this_year, owner), file=out_file) print('=>', path) for line in in_file: out_file.write(line) except UnicodeDecodeError: print('Cannot convert path:', path) os.remove(path + '.out') return os.rename(path + '.out', path) os.chmod(path, permissions) def remove(dirs, path): try: dirs.remove(path) except ValueError: pass def do_walk(): try: owner = sys.argv[1] except IndexError: owner = FSF for root, dirs, files in os.walk('.'): if root == '.': remove(dirs, '.git') remove(dirs, '.tox') remove(dirs, 'bin') remove(dirs, 'contrib') remove(dirs, 'develop-eggs') remove(dirs, 'eggs') remove(dirs, 'parts') remove(dirs, 'gnu-COPYING-GPL') remove(dirs, '.installed.cfg') remove(dirs, '.bzrignore') remove(dirs, 'distribute_setup.py') if root == './src': remove(dirs, 'mailman.egg-info') if root == './src/mailman': remove(dirs, 'messages') for file_name in files: if os.path.splitext(file_name)[1] in ('.pyc', '.gz', '.egg'): continue path = os.path.join(root, file_name) if os.path.isfile(path): do_file(path, owner) if __name__ == '__main__': do_walk() mailmanclient-3.2.2/index.rst0000664000175000017500000000016213427702377017327 0ustar maxkingmaxking00000000000000.. include:: README.rst Contents ======== .. toctree:: :glob: :maxdepth: 2 src/mailmanclient/docs/* mailmanclient-3.2.2/mailman_test.cfg0000664000175000017500000000050313413656045020616 0ustar maxkingmaxking00000000000000# Example Mailman config file suitable for testing [devmode] enabled: yes testing: yes recipient: you@yourdomain.com [mta] smtp_port: 9025 lmtp_port: 9024 incoming: mailman.testing.mta.FakeMTA [webservice] port: 9001 [archiver.mhonarc] enable: yes [archiver.mail_archive] enable: yes [archiver.prototype] enable: yes mailmanclient-3.2.2/pytest.ini0000664000175000017500000000010413413656045017506 0ustar maxkingmaxking00000000000000[pytest] addopts = --doctest-glob='*.rst' --tb=short --run-services mailmanclient-3.2.2/setup.cfg0000664000175000017500000000016313427703737017311 0ustar maxkingmaxking00000000000000[build_sphinx] source_dir = . [upload_docs] upload_dir = build/sphinx/html [egg_info] tag_build = tag_date = 0 mailmanclient-3.2.2/setup.py0000664000175000017500000000353113427702377017203 0ustar maxkingmaxking00000000000000# Copyright (C) 2010-2019 by the Free Software Foundation, Inc. # # This file is part of mailman.client. # # mailman.client is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, either version 3 of the License, or (at your # option) any later version. # # mailman.client is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or # FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License # for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailman.client. If not, see . from setup_helpers import get_version, require_python from setuptools import setup, find_packages require_python(0x30500f0) __version__ = get_version('src/mailmanclient/constants.py') def readme(): with open('README.rst') as fd: return fd.read() setup( name='mailmanclient', version=__version__, packages=find_packages('src'), description='mailmanclient -- Python bindings for Mailman REST API', long_description=readme(), long_description_content_type='text/x-rst', package_dir={'': 'src'}, include_package_data=True, maintainer='Barry Warsaw', maintainer_email='barry@list.org', license='LGPLv3', url='http://www.list.org/', classifiers=[ 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', # noqa 'Operating System :: POSIX', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Topic :: Internet :: WWW/HTTP ', ], install_requires=[ 'httplib2', 'six', ], ) mailmanclient-3.2.2/setup_helpers.py0000664000175000017500000001127713413656572020733 0ustar maxkingmaxking00000000000000# Copyright (C) 2009-2019 by the Free Software Foundation, Inc. # # This file is part of mailman.client # # mailman.client is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailman.client is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more # details. # # You should have received a copy of the GNU Lesser General Public License # along with mailman.client. If not, see . """setup.py helper functions.""" from __future__ import absolute_import, print_function, unicode_literals import os import re import sys import codecs __metaclass__ = type __all__ = [ 'description', 'find_doctests', 'get_version', 'long_description', 'require_python', ] DEFAULT_VERSION_RE = re.compile(r'(?P\d+\.\d(?:\.\w+)?)') NL = '\n' def require_python(minimum): """Require at least a minimum Python version. The version number is expressed in terms of `sys.hexversion`. E.g. to require a minimum of Python 2.6, use:: >>> require_python(0x206000f0) :param minimum: Minimum Python version supported. :type minimum: integer """ if sys.hexversion < minimum: hversion = hex(minimum)[2:] if len(hversion) % 2 != 0: hversion = '0' + hversion split = list(hversion) parts = [] while split: parts.append(int(''.join((split.pop(0), split.pop(0))), 16)) major, minor, micro, release = parts if release == 0xf0: print('Python {0}.{1}.{2} or better is required'.format( major, minor, micro)) else: print('Python {0}.{1}.{2} ({3}) or better is required'.format( major, minor, micro, hex(release)[2:])) sys.exit(1) def get_version(filename, pattern=None): """Extract the __version__ from a file without importing it. While you could get the __version__ by importing the module, the very act of importing can cause unintended consequences. For example, Distribute's automatic 2to3 support will break. Instead, this searches the file for a line that starts with __version__, and extract the version number by regular expression matching. By default, two or three dot-separated digits are recognized, but by passing a pattern parameter, you can recognize just about anything. Use the `version` group name to specify the match group. :param filename: The name of the file to search. :type filename: string :param pattern: Optional alternative regular expression pattern to use. :type pattern: string :return: The version that was extracted. :rtype: string """ if pattern is None: cre = DEFAULT_VERSION_RE else: cre = re.compile(pattern) with open(filename) as fp: for line in fp: if line.startswith('__version__'): mo = cre.search(line) assert mo, 'No valid __version__ string found' return mo.group('version') raise AssertionError('No __version__ assignment found') def find_doctests(start='.', extension='.txt'): """Find separate-file doctests in the package. This is useful for Distribute's automatic 2to3 conversion support. The `setup()` keyword argument `convert_2to3_doctests` requires file names, which may be difficult to track automatically as you add new doctests. :param start: Directory to start searching in (default is cwd) :type start: string :param extension: Doctest file extension (default is .txt) :type extension: string :return: The doctest files found. :rtype: list """ doctests = [] for dirpath, dirnames, filenames in os.walk(start): doctests.extend(os.path.join(dirpath, filename) for filename in filenames if filename.endswith(extension)) return doctests def long_description(*filenames): """Provide a long description.""" res = [] for value in filenames: base, ext = os.path.splitext(value) if ext in ('.txt', '.rst'): with codecs.open(value, 'r', encoding='utf-8') as fp: value = fp.read() res.append(value) if not value.endswith(NL): res.append('') return NL.join(res) def description(filename): """Provide a short description.""" with codecs.open(filename, 'r', encoding='utf-8') as fp: for line in fp: return line.strip() mailmanclient-3.2.2/src/0000775000175000017500000000000013427703737016257 5ustar maxkingmaxking00000000000000mailmanclient-3.2.2/src/mailmanclient/0000775000175000017500000000000013427703737021074 5ustar maxkingmaxking00000000000000mailmanclient-3.2.2/src/mailmanclient/__init__.py0000664000175000017500000000441013413656572023203 0ustar maxkingmaxking00000000000000# Copyright (C) 2010-2019 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . # # flake8: noqa """Package contents.""" from __future__ import absolute_import, print_function, unicode_literals import six from mailmanclient.client import Client from mailmanclient.constants import __version__ from mailmanclient.restbase.connection import MailmanConnectionError from mailmanclient.restobjects.address import Address, Addresses from mailmanclient.restobjects.ban import Bans, BannedAddress from mailmanclient.restobjects.configuration import Configuration from mailmanclient.restobjects.domain import Domain from mailmanclient.restobjects.header_match import HeaderMatch, HeaderMatches from mailmanclient.restobjects.held_message import HeldMessage from mailmanclient.restobjects.archivers import ListArchivers from mailmanclient.restobjects.mailinglist import MailingList from mailmanclient.restobjects.member import Member from mailmanclient.restobjects.preferences import Preferences, PreferencesMixin from mailmanclient.restobjects.queue import Queue from mailmanclient.restobjects.settings import Settings from mailmanclient.restobjects.user import User __metaclass__ = type __all__ = [ 'Address', 'Addresses', 'Bans', 'BannedAddress', 'Client', 'Configuration', 'Domain' 'HeaderMatch', 'HeaderMatches', 'HeldMessage', 'ListArchivers', 'MailingList', 'MailmanConnectionError', 'Member', 'Preferences', 'PreferencesMixin', 'Queue', 'Settings', 'User', '__version__', ] if six.PY2: __all__ = [str(x) for x in __all__] elif six.PY3: __all__ = [bytes(x, 'utf-8') for x in __all__] mailmanclient-3.2.2/src/mailmanclient/_client.py0000664000175000017500000000215513413656572023065 0ustar maxkingmaxking00000000000000# Copyright (C) 2017-2019 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailman.client is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailman.client is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailman.client. If not, see . """Old module for backwards compatibility""" from __future__ import absolute_import, print_function, unicode_literals from mailmanclient import * # noqa __metaclass__ = type # XXX: This module exists for backwards compatibility with older versions of # MailmanClient which had all the API under this module. It will be removed in # future after few cycles of deprecation. mailmanclient-3.2.2/src/mailmanclient/client.py0000664000175000017500000003630313413656572022730 0ustar maxkingmaxking00000000000000# Copyright (C) 2010-2019 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . """Client code.""" from __future__ import absolute_import, unicode_literals import warnings from operator import itemgetter from mailmanclient.constants import (MISSING) from mailmanclient.restobjects.address import Address from mailmanclient.restobjects.ban import Bans, BannedAddress from mailmanclient.restobjects.configuration import Configuration from mailmanclient.restobjects.domain import Domain from mailmanclient.restobjects.mailinglist import MailingList from mailmanclient.restobjects.member import Member from mailmanclient.restobjects.preferences import Preferences from mailmanclient.restobjects.queue import Queue from mailmanclient.restobjects.styles import Styles from mailmanclient.restobjects.user import User from mailmanclient.restobjects.templates import Template, TemplateList from mailmanclient.restbase.connection import Connection from mailmanclient.restbase.page import Page __metaclass__ = type __all__ = [ 'Client' ] # # --- The following classes are part of the API # class Client: """Access the Mailman REST API root. :param baseurl: The base url to access the Mailman 3 REST API. :param name: The Basic Auth user name. If given, the `password` must also be given. :param password: The Basic Auth password. If given the `name` must also be given. """ def __init__(self, baseurl, name=None, password=None): """Initialize client access to the REST API.""" self._connection = Connection(baseurl, name, password) def __repr__(self): return ''.format( self._connection) @property def system(self): """Get the basic system information. :returns: System information about Mailman Core :rtype: Dict[str, str] """ return self._connection.call('system/versions')[1] @property def preferences(self): """Get all default system Preferences. :returns: System preferences. :rtype: :class:`Preferences` """ return Preferences(self._connection, 'system/preferences') @property def configuration(self): """Get the system configuration. :returns: All the system configuration. :rtype: Dict[str, :class:`Configuration`] """ response, content = self._connection.call('system/configuration') return {section: Configuration( self._connection, section) for section in content['sections']} @property def pipelines(self): """Get a list of all Pipelines. :returns: A list of all the pipelines in Core. :rtype: List """ response, content = self._connection.call('system/pipelines') return content @property def chains(self): """Get a list of all the Chains. :returns: A list of all the chains in Core. :rtype: List """ response, content = self._connection.call('system/chains') return content @property def queues(self): """Get a list of all Queues. :returns: A list of all the queues in Core. :rtype: List """ response, content = self._connection.call('queues') queues = {} for entry in content['entries']: queues[entry['name']] = Queue( self._connection, entry['self_link'], entry) return queues @property def styles(self): """All the default styles in Mailman Core. :returns: All the styles in Core. :rtype: :class:`Styles` """ return Styles(self._connection, 'lists/styles') @property def lists(self): """Get a list of all MailingLists. :returns: All the mailing lists. :rtype: list(:class:`MailingList`) """ return self.get_lists() def get_lists(self, advertised=False): """Get a list of all the MailingLists. :param advertised: If marked True, returns all MailingLists including the ones that aren't advertised. :type advertised: bool :returns: A list of mailing lists. :rtype: List(:class:`MailingList`) """ url = 'lists' if advertised: url += '?advertised=true' response, content = self._connection.call(url) if 'entries' not in content: return [] return [MailingList(self._connection, entry['self_link'], entry) for entry in content['entries']] def get_list_page(self, count=50, page=1, advertised=None): """Get a list of all MailingList with pagination. :param count: Number of entries per-page (defaults to 50). :param page: The page number to return (defaults to 1). :param advertised: If marked True, returns all MailingLists including the ones that aren't advertised. """ url = 'lists' if advertised: url += '?advertised=true' return Page(self._connection, url, MailingList, count, page) @property def domains(self): """Get a list of all Domains. :returns: All the domains on the system. :rtype: List[:class:`Domain`] """ response, content = self._connection.call('domains') if 'entries' not in content: return [] return [Domain(self._connection, entry['self_link']) for entry in sorted(content['entries'], key=itemgetter('mail_host'))] @property def members(self): """Get a list of all the Members. :returns: All the list memebrs. :rtype: List[:class:`Member`] """ response, content = self._connection.call('members') if 'entries' not in content: return [] return [Member(self._connection, entry['self_link'], entry) for entry in content['entries']] def get_member(self, fqdn_listname, subscriber_address): """Get the Member object for a given MailingList and Subsciber's Email Address. :param str fqdn_listname: Fully qualified address for the MailingList. :param str subscriber_address: Email Address for the subscriber. :returns: A member of a list. :rtype: :class:`Member` """ return self.get_list(fqdn_listname).get_member(subscriber_address) def get_member_page(self, count=50, page=1): """Return a paginated list of Members. :param int count: Number of items to return. :param int page: The page number. :returns: Paginated lists of members. :rtype: :class:`Page` of :class:`Member`. """ return Page(self._connection, 'members', Member, count, page) @property def users(self): """Get all the users. :returns: All the users in Mailman Core. :rtype: List[:class:`User`] """ response, content = self._connection.call('users') if 'entries' not in content: return [] return [User(self._connection, entry['self_link'], entry) for entry in sorted(content['entries'], key=itemgetter('self_link'))] def get_user_page(self, count=50, page=1): """Get all the users with pagination. :param int count: Number of entries per-page (defaults to 50). :param int page: The page number to return (defaults to 1). :returns: Paginated list of users on Mailman. :rtype: :class:`Page` of :class:`User` """ return Page(self._connection, 'users', User, count, page) def create_domain(self, mail_host, base_url=MISSING, description=None, owner=None, alias_domain=None): """Create a new Domain. :param str mail_host: The Mail host for the new domain. If you want foo@bar.com" as the address for your MailingList, use "bar.com" here. :param str description: A brief description for this Domain. :param str owner: Email address for the owner of this list. :param str alias_domain: Alias domain. :returns: The created Domain. :rtype: :class:`Domain` """ if base_url is not MISSING: warnings.warn( 'The `base_url` parameter in the `create_domain()` method is ' 'deprecated. It is not used any more and will be removed in ' 'the future.', DeprecationWarning, stacklevel=2) data = dict(mail_host=mail_host) if description is not None: data['description'] = description if owner is not None: data['owner'] = owner if alias_domain is not None: data['alias_domain'] = alias_domain response, content = self._connection.call('domains', data) return Domain(self._connection, response['location']) def delete_domain(self, mail_host): """Delete a Domain. :param str mail_host: The Mail host for the domain you want to delete. """ response, content = self._connection.call( 'domains/{0}'.format(mail_host), None, 'DELETE') def get_domain(self, mail_host, web_host=MISSING): """Get Domain by its mail_host.""" if web_host is not MISSING: warnings.warn( 'The `web_host` parameter in the `get_domain()` method is ' 'deprecated. It is not used any more and will be removed in ' 'the future.', DeprecationWarning, stacklevel=2) response, content = self._connection.call( 'domains/{0}'.format(mail_host)) return Domain(self._connection, content['self_link']) def create_user(self, email, password, display_name=''): """Create a new User. :param str email: Email address for the new user. :param str password: Password for the new user. :param str display_name: An optional name for the new user. :returns: The created user instance. :rtype: :class:`User` """ response, content = self._connection.call( 'users', dict(email=email, password=password, display_name=display_name)) return User(self._connection, response['location']) def get_user(self, address): """Given an Email Address, return the User it belongs to. :param str address: Email Address of the User. :returns: The user instance that owns the address. :rtype: :class:`User` """ response, content = self._connection.call( 'users/{0}'.format(address)) return User(self._connection, content['self_link'], content) def get_address(self, address): """Given an Email Address, return the Address object. :param str address: Email address. :returns: The Address object for given email address. :rtype: :class:`Address` """ response, content = self._connection.call( 'addresses/{0}'.format(address)) return Address(self._connection, content['self_link'], content) def get_list(self, fqdn_listname): """Get a MailingList object. :param str fqdn_listname: Fully qualified name of the MailingList. :returns: The mailing list object of the given fqdn_listname. :rtype: :class:`MailingList` """ response, content = self._connection.call( 'lists/{0}'.format(fqdn_listname)) return MailingList(self._connection, content['self_link'], content) def delete_list(self, fqdn_listname): """Delete a MailingList. :param str fqdn_listname: Fully qualified name of the MailingList. """ response, content = self._connection.call( 'lists/{0}'.format(fqdn_listname), None, 'DELETE') @property def bans(self): """Get a list of all the bans. :returns: A list of all the bans. :rtype: :class:`Bans` """ return Bans(self._connection, 'bans', mlist=None) def get_bans_page(self, count=50, page=1): """Get a list of all the bans with pagination. :param int count: Number of entries per-page (defaults to 50). :param int page: The page number to return (defaults to 1). :returns: Paginated list of banned addresses. :rtype: :class:`Page` of :class:`BannedAddress` """ return Page(self._connection, 'bans', BannedAddress, count, page) @property def templates(self): """Get all site-context templates. :returns: List of templates for the site context. :rtype: :class:`TemplateList` """ return TemplateList(self._connection, 'uris') def get_templates_page(self, count=25, page=1): """Get paginated site-context templates. :returns: Paginated list of templates of site context. :rtype: :class:`Page` of :class:`Template` """ return Page(self._connection, 'uris', Template, count, page) def set_template(self, template_name, url, username=None, password=None): """Set template in site-context. :param str template_name: The template to set. :param str url: The URL to fetch the template from. :param str username: Username for access to the template. :param str password: Password for the ``username`` to access templates. """ data = {template_name: url} if username is not None and password is not None: data['username'] = username data['password'] = password return self._connection.call('uris', data, 'PATCH')[1] def find_lists(self, subscriber, role=None, count=50, page=1): """Given a subscriber and a role, return all the list they are subscribed to with given role. If no role is specified all the related mailing lists are returned without duplicates, even though there can potentially be multiple memberships of a user in a single mailing list. :param str subscriber: The address of the subscriber. :param str role: owner, moderator or subscriber :returns: A filtered list of mailing lists with given filters. :rtype: List[:class:`MailingList`] """ url = 'lists/find' data = dict(subscriber=subscriber, count=count, page=page) if role is not None: data['role'] = role response, content = self._connection.call(url, data) if 'entries' not in content: return [] return [MailingList(self._connection, entry['self_link'], entry) for entry in content['entries']] mailmanclient-3.2.2/src/mailmanclient/conftest.py0000664000175000017500000000306613413656572023277 0ustar maxkingmaxking00000000000000# Copyright (C) 2017-2019 by the Free Software Foundation, Inc. # # This file is part of mailman.client. # # mailman.client is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailman.client is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailman.client. If not, see . """Testing utilities.""" import pytest import subprocess import socket from contextlib import closing def check_core(): with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as sock: if sock.connect_ex(('localhost', 9001)) == 0: return True else: return False @pytest.fixture(scope='module', autouse=True) def mailman_core(request, watcher_getter): """Mailman Core instance which is ready to be used by the tests""" def teardown_core(): print('Stopping Mailman Server') subprocess.run(['mailman', 'stop']) subprocess.run(['rm', '-rf', 'var/']) request.addfinalizer(teardown_core) print('Starting Mailman Server') return watcher_getter( name='master', arguments=['-C', 'mailman_test.cfg'], checker=check_core, request=request, ) mailmanclient-3.2.2/src/mailmanclient/constants.py0000664000175000017500000000142513413671033023450 0ustar maxkingmaxking00000000000000# Copyright (C) 2010-2019 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . __version__ = '3.2.2' DEFAULT_PAGE_ITEM_COUNT = 50 MISSING = object() mailmanclient-3.2.2/src/mailmanclient/docs/0000775000175000017500000000000013427703737022024 5ustar maxkingmaxking00000000000000mailmanclient-3.2.2/src/mailmanclient/docs/NEWS.rst0000664000175000017500000000465513427702456023341 0ustar maxkingmaxking00000000000000======================= NEWS for mailmanclient ======================= 3.2.2 (2019-02-09) ================== 3.2.1 (2019-01-04) ================== * Add support for Python 3.7 * Add ``description`` as a property of ``MailingList``. Initially, this was a part of ``Preferences`` object, which would mean an additional API call to get the description of a Mailing List. (Closes #35) * ``MailingList.get_members`` no longer requires ``address`` as a mandatory argument which allows searching for all memberships of of a particular role. Also, ``role`` no longer has a default argument, so that we can search for all memberships of an address. 3.2.0 (2018-07-10) ================== Changes ------- * Add '.pc' (patch directory) to list of ignored patterns when building the documentation with Sphinx. * `Mailinglist.add_owner` and `Mailinglist.add_moderator` now accept an additional `display_name` argument that allows associating display names with these memberships. * Add a new API ``Client.find_lists`` which allows filtering mailing lists related to a subscriber. It optionally allows a role, which filters the lists that the address is subscribed to with that role. Backwards Incompatible Changes ------------------------------- * `MailingList.owners` and `MailingList.moderators` now returns a list of `Member` objects instead of a list of emails. * `Domain.owners` now returns a list of `User` objects instead of just a dictionary of JSON response. (!63) * Python 2.7 is no longer supported. 3.1.1 (2017-10-07) ================== * Python3 compatibility is fixed, mailmanclient is now compatible through Python2.7 - Python3.6 * Internal source code is now split into several class-specific modules as compared to previously a single giant _client module. * All the RestObjects, like MailingList, are now exposed from the top level import. * Old `mailmanclient._client` module is added back for compatibility with versions of Postorius that use some internal APIs. 3.1 (2017-05-25) ================ * Bug fixes. * Align with Mailman 3.1 Core REST API. * Python3 compatibility is broken because of a urllib bug. 1.0.1 (2015-11-14) ================== * Bugfix release. 1.0.0 (2015-04-17) ================== * Port to Python 3.4. * Run test suite with `tox`. * Use vcrpy for HTTP testing. * Add list archiver access. * Add subscription moderation 1.0.0a1 (2014-03-15) ==================== * Initial release. mailmanclient-3.2.2/src/mailmanclient/docs/__init__.py0000664000175000017500000000000013413656045024115 0ustar maxkingmaxking00000000000000mailmanclient-3.2.2/src/mailmanclient/docs/apiref.rst0000664000175000017500000000232013413656045024013 0ustar maxkingmaxking00000000000000============= API Reference ============= .. autoclass:: mailmanclient.Client :members: :undoc-members: :private-members: :inherited-members: .. autoclass:: mailmanclient.Domain :members: :undoc-members: .. autoclass:: mailmanclient.MailingList :members: :undoc-members: .. autoclass:: mailmanclient.ListArchivers :members: :undoc-members: .. autoclass:: mailmanclient.Bans :members: :undoc-members: .. autoclass:: mailmanclient.BannedAddress :members: :undoc-members: .. autoclass:: mailmanclient.HeaderMatches :members: :undoc-members: .. autoclass:: mailmanclient.HeaderMatch :members: :undoc-members: .. autoclass:: mailmanclient.Member :members: :undoc-members: .. autoclass:: mailmanclient.User :members: :undoc-members: .. autoclass:: mailmanclient.Addresses :members: :undoc-members: .. autoclass:: mailmanclient.Address :members: :undoc-members: .. autoclass:: mailmanclient.HeldMessage :members: :undoc-members: .. autoclass:: mailmanclient.Preferences :members: :undoc-members: .. autoclass:: mailmanclient.Settings :members: :undoc-members: .. autoclass:: mailmanclient.Queue :members: :undoc-members: mailmanclient-3.2.2/src/mailmanclient/docs/conftest.py0000664000175000017500000000242213413656572024222 0ustar maxkingmaxking00000000000000# Copyright (C) 2017-2019 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . """Wrappers for doctests to run with pytest""" from __future__ import absolute_import, print_function, unicode_literals import pytest from mailmanclient.testing.documentation import dump def pytest_collection_modifyitems(items): for item in items: item.add_marker(pytest.mark.vcr) @pytest.fixture(autouse=True) def import_stuff(doctest_namespace): doctest_namespace['absolute_import'] = absolute_import doctest_namespace['print_function'] = print_function doctest_namespace['unicode_literals'] = unicode_literals doctest_namespace['dump'] = dump mailmanclient-3.2.2/src/mailmanclient/docs/testing.rst0000664000175000017500000000153613413656045024232 0ustar maxkingmaxking00000000000000======================== Developing MailmanClient ======================== Running Tests ============= The test suite is run with the `tox`_ tool, which allows it to be run against multiple versions of Python. The tests are discovered and run using `pytest`_. To run the test suite, run:: $ tox To run tests for only one version of Python, you can run:: $ tox -e py36 ``pytest`` starts Mailman Core using ``pytest-services`` plugin and automatically manages it's start and stop cycle for every module. .. note:: Previously, we used ``vcrpy`` and ``pytest-vcr`` packages to manage recorded tapes for interaction with Mailman Core. That was replaced with ``pytest-services`` plugin, which instead start Core for every test. .. _`tox`: https://testrun.org/tox/latest/ .. _`pytest`: https://docs.pytest.org/en/latest/ mailmanclient-3.2.2/src/mailmanclient/docs/using.rst0000644000175000017500000010466313413656045023705 0ustar maxkingmaxking00000000000000============= Example Usage ============= This is the official Python bindings for the GNU Mailman REST API. In order to talk to Mailman, the engine's REST server must be running. You begin by instantiating a client object to access the root of the REST hierarchy, providing it the base URL, user name and password (for Basic Auth). >>> from mailmanclient import Client >>> client = Client('http://localhost:9001/3.1', 'restadmin', 'restpass') .. note:: Please note that port '9001' is used above, since mailman's test server runs on port *9001*. In production Mailman's REST API usually listens on port *8001*. We can retrieve basic information about the server. >>> dump(client.system) api_version: 3.1 http_etag: "..." mailman_version: GNU Mailman ... (...) python_version: ... self_link: http://localhost:9001/3.1/system/versions To start with, there are no known mailing lists. >>> client.lists [] Domains ======= Before new mailing lists can be added, the domain that the list will live in must be added. By default, there are no known domains. >>> client.domains [] It's easy to create a new domain; when you do, a proxy object for that domain is returned. >>> example_dot_com = client.create_domain('example.com') >>> print(example_dot_com.description) None >>> print(example_dot_com.mail_host) example.com >>> print(example_dot_com.alias_domain) None A domain can have an alias_domain attribute to help with some unusual Postfix configurations. >>> example_dot_edu = client.create_domain('example.edu', ... alias_domain='x.example.edu') >>> print(example_dot_edu.mail_host) example.edu >>> print(example_dot_edu.alias_domain) x.example.edu You can also get an existing domain independently using its mail host. >>> example = client.get_domain('example.com') >>> print(example.mail_host) example.com After creating a few more domains, we can print the list of all domains. >>> example_net = client.create_domain('example.net') >>> example_org = client.create_domain('example.org') >>> print(example_org.mail_host) example.org >>> for domain in client.domains: ... print(domain.mail_host) example.com example.edu example.net example.org Also, domain can be deleted. >>> example_org.delete() >>> for domain in client.domains: ... print(domain.mail_host) example.com example.edu example.net Mailing lists ============= Once you have a domain, you can create mailing lists in that domain. >>> test_one = example.create_list('test-1') >>> print(test_one.fqdn_listname) test-1@example.com >>> print(test_one.mail_host) example.com >>> print(test_one.list_name) test-1 >>> print(test_one.display_name) Test-1 You can create a mailing list with a specific list style. >>> test_two = example.create_list('test-announce', style_name='legacy-announce') >>> print(test_two.fqdn_listname) test-announce@example.com You can retrieve a list of known mailing list styles along with the default one. >>> styles = client.styles >>> from operator import itemgetter >>> for style in sorted(styles['styles'], key=itemgetter('name')): ... print('{0}: {1}'.format(style['name'], style['description'])) legacy-announce: Announce only mailing list style. legacy-default: Ordinary discussion mailing list style. private-default: Discussion mailing list style with private archives. >>> print(styles['default']) legacy-default You can also retrieve the mailing list after the fact. >>> my_list = client.get_list('test-1@example.com') >>> print(my_list.fqdn_listname) test-1@example.com And you can print all the known mailing lists. :: >>> print(example.create_list('test-2').fqdn_listname) test-2@example.com >>> domain = client.get_domain('example.net') >>> print(domain.create_list('test-3').fqdn_listname) test-3@example.net >>> print(example.create_list('test-3').fqdn_listname) test-3@example.com >>> for mlist in client.lists: ... print(mlist.fqdn_listname) test-1@example.com test-2@example.com test-3@example.com test-3@example.net test-announce@example.com You can also select advertised lists only. :: >>> my_list.settings['advertised'] = False >>> my_list.settings.save() >>> for mlist in client.get_lists(advertised=True): ... print(mlist.fqdn_listname) test-2@example.com test-3@example.com test-3@example.net test-announce@example.com List results can be retrieved as pages: >>> page = client.get_list_page(count=2, page=1) >>> page.nr 1 >>> len(page) 2 >>> page.total_size 5 >>> for m_list in page: ... print(m_list.fqdn_listname) test-1@example.com test-2@example.com >>> page = page.next >>> page.nr 2 >>> for m_list in page: ... print(m_list.fqdn_listname) test-3@example.com test-3@example.net Pages can also use the advertised filter: >>> page = client.get_list_page(count=2, page=1, advertised=True) >>> for m_list in page: ... print(m_list.fqdn_listname) test-2@example.com test-3@example.com If you only want to know all lists for a specific domain, use the domain object. >>> for mlist in example.lists: ... print(mlist.fqdn_listname) test-1@example.com test-2@example.com test-3@example.com test-announce@example.com It is also possible to display only advertised lists when using the domain. >>> for mlist in example.get_lists(advertised=True): ... print(mlist.fqdn_listname) test-2@example.com test-3@example.com test-announce@example.com >>> for mlist in example.get_list_page(count=2, page=1, advertised=True): ... print(mlist.fqdn_listname) test-2@example.com test-3@example.com You can use a list instance to delete the list. >>> test_three = client.get_list('test-3@example.net') >>> test_three.delete() You can also delete a list using the client instance's delete_list method. >>> client.delete_list('test-3@example.com') >>> for mlist in client.lists: ... print(mlist.fqdn_listname) test-1@example.com test-2@example.com test-announce@example.com Membership ========== Email addresses can subscribe to existing mailing lists, becoming members of that list. The address is a unique id for a specific user in the system, and a member is a user that is subscribed to a mailing list. Email addresses need not be pre-registered, though the auto-registered user will be unique for each email address. The system starts out with no members. >>> client.members [] New members can be easily added; users are automatically registered. :: >>> test_two = client.get_list('test-2@example.com') >>> print(test_two.settings['subscription_policy']) confirm Email addresses need to be verified first, so if we try to subscribe a user, we get a response with a token: >>> data = test_one.subscribe('unverified@example.com', 'Unverified') >>> data['token'] is not None True >>> print(data['token_owner']) subscriber If we know the email address to be valid, we can set the ``pre_verified`` flag. However, the list's subscription policy is "confirm", so if we try to subscribe a user, we will also get a token back: >>> data = test_one.subscribe('unconfirmed@example.com', ... 'Unconfirmed', ... pre_verified=True) >>> data['token'] is not None True >>> print(data['token_owner']) subscriber If we know the user originated the subscription (for example if she or he has been authenticated elsewhere), we can set the ``pre_confirmed`` flag. The ``pre_approved`` flag is used for lists that require moderator approval and should only be used if the subscription is initiated by a moderator or admin. >>> print(test_one.subscribe('anna@example.com', 'Anna', ... pre_verified=True, ... pre_confirmed=True)) Member "anna@example.com" on "test-1.example.com" >>> print(test_one.subscribe('bill@example.com', 'Bill', ... pre_verified=True, ... pre_confirmed=True)) Member "bill@example.com" on "test-1.example.com" >>> print(test_two.subscribe('anna@example.com', ... pre_verified=True, ... pre_confirmed=True)) Member "anna@example.com" on "test-2.example.com" >>> print(test_two.subscribe('cris@example.com', 'Cris', ... pre_verified=True, ... pre_confirmed=True)) Member "cris@example.com" on "test-2.example.com" We can retrieve all known memberships. These are sorted first by mailing list name, then by email address. >>> for member in client.members: ... print(member) Member "anna@example.com" on "test-1.example.com" Member "bill@example.com" on "test-1.example.com" Member "anna@example.com" on "test-2.example.com" Member "cris@example.com" on "test-2.example.com" We can also view the memberships for a single mailing list. >>> for member in test_one.members: ... print(member) Member "anna@example.com" on "test-1.example.com" Member "bill@example.com" on "test-1.example.com" Membership may have a name associated, this depends on whether the member ``Address`` or ``User`` has a ``display_name`` attribute. >>> for member in test_one.members: ... print(member.display_name) Anna Bill Membership lists can be paginated, to recieve only a part of the result. >>> page = client.get_member_page(count=2, page=1) >>> page.nr 1 >>> page.total_size 4 >>> for member in page: ... print(member) Member "anna@example.com" on "test-1.example.com" Member "bill@example.com" on "test-1.example.com" >>> page = page.next >>> page.nr 2 >>> for member in page: ... print(member) Member "anna@example.com" on "test-2.example.com" Member "cris@example.com" on "test-2.example.com" >>> page = test_one.get_member_page(count=1, page=1) >>> page.nr 1 >>> page.total_size 2 >>> for member in page: ... print(member) Member "anna@example.com" on "test-1.example.com" >>> page = page.next >>> page.nr 2 >>> page.total_size 2 >>> for member in page: ... print(member) Member "bill@example.com" on "test-1.example.com" We can get a single membership too. >>> cris_test_two = test_two.get_member('cris@example.com') >>> print(cris_test_two) Member "cris@example.com" on "test-2.example.com" >>> print(cris_test_two.role) member >>> print(cris_test_two.display_name) Cris A membership can also be retrieved without instantiating the list object first: >>> print(client.get_member('test-2@example.com', 'cris@example.com')) Member "cris@example.com" on "test-2.example.com" A membership has preferences. >>> prefs = cris_test_two.preferences >>> print(prefs['delivery_mode']) None >>> print(prefs['acknowledge_posts']) None >>> print(prefs['delivery_status']) None >>> print(prefs['hide_address']) None >>> print(prefs['preferred_language']) None >>> print(prefs['receive_list_copy']) None >>> print(prefs['receive_own_postings']) None The membership object's ``user`` attribute will return a User object: >>> cris_u = cris_test_two.user >>> print(cris_u.display_name, cris_u.user_id) Cris ... If you use an address which is not a member of test_two `ValueError` is raised: >>> test_two.unsubscribe('nomember@example.com') Traceback (most recent call last): ... ValueError: nomember@example.com is not a member address of test-2@example.com After a while, Anna decides to unsubscribe from the Test One mailing list, though she keeps her Test Two membership active. >>> import time >>> time.sleep(2) >>> test_one.unsubscribe('anna@example.com') >>> for member in client.members: ... print(member) Member "bill@example.com" on "test-1.example.com" Member "anna@example.com" on "test-2.example.com" Member "cris@example.com" on "test-2.example.com" A little later, Cris decides to unsubscribe from the Test Two mailing list. >>> cris_test_two.unsubscribe() >>> for member in client.members: ... print(member) Member "bill@example.com" on "test-1.example.com" Member "anna@example.com" on "test-2.example.com" If you try to unsubscribe an address which is not a member address `ValueError` is raised: >>> test_one.unsubscribe('nomember@example.com') Traceback (most recent call last): ... ValueError: nomember@example.com is not a member address of test-1@example.com Non-Members =========== When someone attempts to post to a list but is not a member, then they are listed as a "non-member" of that list so that a moderator can choose how to handle their messages going forward. In some cases, one might wish to accept or reject their future messages automatically. Just like with regular members, they are given a unique id. The list starts out with no nonmembers. >>> test_one.nonmembers [] When someone tries to send a message to the list and they are not a subscriber, they get added to the nonmember list. Users ===== Users are people with one or more list memberships. To get a list of all users, access the clients user property. >>> for user in client.users: ... print(user.display_name) Unverified Unconfirmed Anna Bill Cris The list of users can also be paginated: >>> page = client.get_user_page(count=4, page=1) >>> page.nr 1 >>> page.total_size 5 >>> for user in page: ... print(user.display_name) Unverified Unconfirmed Anna Bill You can get the next or previous pages without calling ``get_userpage`` again. >>> page = page.next >>> page.nr 2 >>> for user in page: ... print(user.display_name) Cris >>> page = page.previous >>> page.nr 1 >>> for user in page: ... print(user.display_name) Unverified Unconfirmed Anna Bill A single user can be retrieved using their email address. >>> cris = client.get_user('cris@example.com') >>> print(cris.display_name) Cris Every user has a list of one or more addresses. >>> for address in cris.addresses: ... print(address) ... print(address.display_name) ... print(address.registered_on) cris@example.com Cris ... Multiple addresses can be assigned to a user record: >>> print(cris.add_address('cris.person@example.org')) cris.person@example.org >>> print(client.get_address('cris.person@example.org')) cris.person@example.org >>> for address in cris.addresses: ... print(address) cris.person@example.org cris@example.com Trying to add an existing address will raise an error: >>> dana = client.create_user(email='dana@example.org', ... password='somepass', ... display_name='Dana') >>> print(dana.display_name) Dana >>> cris.add_address('dana@example.org') # doctest: +IGNORE_EXCEPTION_DETAIL Traceback (most recent call last): ... HTTPError: HTTP Error 400: Address already exists This can be overridden by using the ``absorb_existing`` flag: >>> print(cris.add_address('dana@example.org', absorb_existing=True)) dana@example.org The user Chris will then be merged with Dana, acquiring all its subscriptions and preferences. In case of conflict, Chris' original preferences will prevail. >>> for address in cris.addresses: ... print(address) cris.person@example.org cris@example.com dana@example.org Addresses ========= Addresses can be accessed directly: >>> address = client.get_address('dana@example.org') >>> print(address) dana@example.org >>> print(address.display_name) Dana The address has not been verified: >>> print(address.verified) False But that can be done via the address object: >>> address.verify() >>> print(address.verified) True It can also be unverified: >>> address.unverify() >>> print(address.verified) False Addresses can be deleted by calling their ``delete()`` method or by removing them from their user's ``addresses`` list: >>> cris.addresses.remove('dana@example.org') >>> for address in cris.addresses: ... print(address) cris.person@example.org cris@example.com Users can be added using ``create_user``. The display_name is optional: >>> ler = client.create_user(email='ler@primus.org', ... password='somepass', ... display_name='Ler') >>> print(ler.display_name) Ler >>> ler = client.get_user('ler@primus.org') >>> print(ler.password) $... >>> print(ler.display_name) Ler User attributes can be changed through assignment, but you need to call the object's ``save`` method to store the changes in the mailman core database. >>> ler.display_name = 'Sir Ler' >>> ler.save() >>> ler = client.get_user('ler@primus.org') >>> print(ler.display_name) Sir Ler Passwords can be changed as well: >>> old_pwd = ler.password >>> ler.password = 'easy' >>> old_pwd == ler.password True >>> ler.save() >>> old_pwd == ler.password False User Subscriptions ------------------ A User's subscriptions can be access through their ``subscriptions`` property. >>> bill = client.get_user('bill@example.com') >>> for subscription in bill.subscriptions: ... print(subscription) Member "bill@example.com" on "test-1.example.com" If all you need are the list ids of all mailing lists a user is subscribed to, you can use the ``subscription_list_ids`` property. >>> for list_id in bill.subscription_list_ids: ... print(list_id) test-1.example.com List Settings ============= We can get all list settings via a lists settings attribute. A proxy object for the settings is returned which behaves much like a dictionary. >>> settings = test_one.settings >>> len(settings) 59 >>> for attr in sorted(settings): ... print(attr + ': ' + str(settings[attr])) acceptable_aliases: [] ... volume: 1 >>> print(settings['display_name']) Test-1 We can access all valid list settings as attributes. >>> print(settings['fqdn_listname']) test-1@example.com >>> print(settings['description']) >>> settings['description'] = 'A very meaningful description.' >>> settings['display_name'] = 'Test Numero Uno' >>> settings.save() >>> settings_new = test_one.settings >>> print(settings_new['description']) A very meaningful description. >>> print(settings_new['display_name']) Test Numero Uno The settings object also supports the `get` method of usual Python dictionaries: >>> print(settings_new.get('OhNoIForgotTheKey', ... 'HowGoodIPlacedOneUnderTheDoormat')) HowGoodIPlacedOneUnderTheDoormat Preferences =========== Preferences can be accessed and set for users, members and addresses. By default, preferences are not set and fall back to the global system preferences. They're read-only and can be accessed through the client object. >>> global_prefs = client.preferences >>> print(global_prefs['acknowledge_posts']) False >>> print(global_prefs['delivery_mode']) regular >>> print(global_prefs['delivery_status']) enabled >>> print(global_prefs['hide_address']) True >>> print(global_prefs['preferred_language']) en >>> print(global_prefs['receive_list_copy']) True >>> print(global_prefs['receive_own_postings']) True Preferences can be set, but you have to call ``save`` to make your changes permanent. >>> prefs = test_two.get_member('anna@example.com').preferences >>> prefs['delivery_status'] = 'by_user' >>> prefs.save() >>> prefs = test_two.get_member('anna@example.com').preferences >>> print(prefs['delivery_status']) by_user Pipelines and Chains ==================== The available pipelines and chains can also be retrieved: >>> pipelines = client.pipelines['pipelines'] >>> for pipeline in pipelines: ... print(pipeline) default-owner-pipeline default-posting-pipeline virgin >>> chains = client.chains['chains'] >>> for chain in chains: ... print(chain) accept default-owner-chain default-posting-chain discard dmarc header-match hold moderation reject Owners and Moderators ===================== Owners and moderators are properties of the list object. >>> test_one.owners [] >>> test_one.moderators [] Owners can be added via the ``add_owner`` method and they can have an optional ``display_name`` associated like other ``members``: >>> test_one.add_owner('foo@example.com', display_name='Foo') >>> for owner in test_one.owners: ... print(owner.email) foo@example.com The owner of the list not automatically added as a member: >>> for m in test_one.members: ... print(m) Member "bill@example.com" on "test-1.example.com" Moderators can be added similarly: >>> test_one.add_moderator('bar@example.com', display_name='Bar') >>> for moderator in test_one.moderators: ... print(moderator.email) bar@example.com Moderators are also not automatically added as members: >>> for m in test_one.members: ... print(m) Member "bill@example.com" on "test-1.example.com" Members and owners/moderators are separate entries in in the general members list: >>> print(test_one.subscribe('bar@example.com', 'Bar', ... pre_verified=True, ... pre_confirmed=True)) Member "bar@example.com" on "test-1.example.com" >>> for member in client.members: ... print('%s: %s' % (member, member.role)) Member "foo@example.com" on "test-1.example.com": owner Member "bar@example.com" on "test-1.example.com": moderator Member "bar@example.com" on "test-1.example.com": member Member "bill@example.com" on "test-1.example.com": member Member "anna@example.com" on "test-2.example.com": member Both owners and moderators can be removed: >>> test_one.remove_owner('foo@example.com') >>> test_one.owners [] test_one.remove_moderator('bar@example.com') test_one.moderators [] Moderation ========== Subscription Moderation ----------------------- Subscription requests can be accessed through the list object's `request` property. So let's create a non-open list first. >>> confirm_first = example_dot_com.create_list('confirm-first') >>> settings = confirm_first.settings >>> settings['subscription_policy'] = 'moderate' >>> settings.save() >>> confirm_first = client.get_list('confirm-first.example.com') >>> print(confirm_first.settings['subscription_policy']) moderate Initially there are no requests, so let's to subscribe someone to the list. We'll get a token back. >>> confirm_first.requests [] >>> data = confirm_first.subscribe('groucho@example.com', ... pre_verified=True, ... pre_confirmed=True) >>> print(data['token_owner']) moderator Now the request shows up in the list of requests: >>> import time; time.sleep(5) >>> len(confirm_first.requests) 1 >>> request_1 = confirm_first.requests[0] >>> print(request_1['email']) groucho@example.com >>> print (request_1['token'] is not None) True >>> print(request_1['token_owner']) moderator >>> print(request_1['request_date'] is not None) True >>> print(request_1['list_id']) confirm-first.example.com Subscription requests can be accepted, deferred, rejected or discarded using the request token. >>> data = confirm_first.subscribe('harpo@example.com', ... pre_verified=True, ... pre_confirmed=True) >>> data = confirm_first.subscribe('zeppo@example.com', ... pre_verified=True, ... pre_confirmed=True) >>> len(confirm_first.requests) 3 Let's accept Groucho: >>> response = confirm_first.moderate_request(request_1['token'], 'accept') >>> len(confirm_first.requests) 2 >>> request_2 = confirm_first.requests[0] >>> print(request_2['email']) harpo@example.com >>> request_3 = confirm_first.requests[1] >>> print(request_3['email']) zeppo@example.com Let's reject Harpo: >>> response = confirm_first.moderate_request(request_2['token'], 'reject') >>> len(confirm_first.requests) 1 Let's discard Zeppo's request: >>> response = confirm_first.moderate_request(request_3['token'], 'discard') >>> len(confirm_first.requests) 0 Message Moderation ------------------ By injecting a message by a non-member into the incoming queue, we can simulate a message being held for moderator approval. >>> msg = """From: nomember@example.com ... To: test-1@example.com ... Subject: Something ... Message-ID: ... ... Some text. ... ... """ >>> inq = client.queues['in'] >>> inq.inject('test-1.example.com', msg) Now wait until the message has been processed. >>> while True: ... if len(inq.files) == 0: ... break ... time.sleep(0.1) It might take a few moments for the message to show up in the moderation queue. >>> while True: ... all_held = test_one.held ... if len(all_held) > 0: ... break ... time.sleep(0.1) Messages held for moderation can be listed on a per list basis. >>> print(all_held[0].request_id) 1 A held message can be retrieved by ID, and have attributes: >>> heldmsg = test_one.get_held_message(1) >>> print(heldmsg.subject) Something >>> print(heldmsg.reason) The message is not from a list member >>> print(heldmsg.sender) nomember@example.com >>> 'Message-ID: ' in heldmsg.msg True A moderation action can be taken on them using the list methods or the held message's methods. >>> print(test_one.defer_message(heldmsg.request_id)['status']) 204 >>> len(test_one.held) 1 >>> print(heldmsg.discard()['status']) 204 >>> len(test_one.held) 0 Member moderation ----------------- Each member or non-member can have a specific moderation action. It is set using the 'moderation_action' property: >>> bill_member = test_one.get_member('bill@example.com') >>> print(bill_member.moderation_action) None >>> bill_member.moderation_action = 'hold' >>> bill_member.save() >>> print(test_one.get_member('bill@example.com').moderation_action) hold Banning addresses ----------------- A ban list is a list of email addresses that are not allowed to subscribe to a mailing-list. There are two types of ban lists: each mailing-list has its ban list, and there is a site-wide list. Addresses on the site-wide list are prevented from subscribing to every mailing-list on the server. To view the site-wide ban list, use the `bans` property:: >>> list(client.bans) [] You can use the `add` method on the ban list to ban an email address:: >>> banned_anna = client.bans.add('anna@example.com') >>> print(banned_anna) anna@example.com >>> 'anna@example.com' in client.bans True >>> print(client.bans.add('bill@example.com')) bill@example.com >>> for addr in list(client.bans): ... print(addr) anna@example.com bill@example.com The list of banned addresses can be paginated using the ``get_bans_page()`` method:: >>> for addr in list(client.get_bans_page(count=1, page=1)): ... print(addr) anna@example.com >>> for addr in list(client.get_bans_page(count=1, page=2)): ... print(addr) bill@example.com You can use the ``delete()`` method on a banned address to unban it, or the ``remove()`` method on the ban list:: >>> banned_anna.delete() >>> 'anna@example.com' in client.bans False >>> for addr in list(client.bans): ... print(addr) bill@example.com >>> client.bans.remove('bill@example.com') >>> 'bill@example.com' in client.bans False >>> print(list(client.bans)) [] The mailing-list-specific ban lists work in the same way:: >>> print(list(test_one.bans)) [] >>> banned_anna = test_one.bans.add('anna@example.com') >>> 'anna@example.com' in test_one.bans True >>> print(test_one.bans.add('bill@example.com')) bill@example.com >>> for addr in list(test_one.bans): ... print(addr) anna@example.com bill@example.com >>> for addr in list(test_one.get_bans_page(count=1, page=1)): ... print(addr) anna@example.com >>> for addr in list(test_one.get_bans_page(count=1, page=2)): ... print(addr) bill@example.com >>> banned_anna.delete() >>> 'anna@example.com' in test_one.bans False >>> test_one.bans.remove('bill@example.com') >>> print(list(test_one.bans)) [] Archivers ========= Each list object has an ``archivers`` attribute. >>> archivers = test_one.archivers >>> print(archivers) Archivers on test-1.example.com The activation status of each available archiver can be accessed like a key in a dictionary. >>> archivers = test_one.archivers >>> for archiver in sorted(archivers.keys()): ... print('{0}: {1}'.format(archiver, archivers[archiver])) mail-archive: True mhonarc: True prototype: True >>> archivers['mail-archive'] True >>> archivers['mhonarc'] True They can also be set like items in dictionary. >>> archivers['mail-archive'] = False >>> archivers['mhonarc'] = False So if we get a new ``archivers`` object from the API (by accessing the list's archiver attribute again), we can see that the archiver stati have now been set. >>> archivers = test_one.archivers >>> archivers['mail-archive'] False >>> archivers['mhonarc'] False Header matches ============== Header matches are filtering rules that apply to messages sent to a mailing list. They match a header to a pattern using a regular expression, and matching patterns can trigger specific moderation actions. They are accessible via the mailing list's ``header_matches`` attribute, which behaves like a list. >>> header_matches = test_one.header_matches >>> print(header_matches) Header matches for "test-1.example.com" >>> len(header_matches) 0 Header matches can be added using the ``add()`` method. The arguments are: - the header to consider (``str``). Il will be lower-cased. - the regular expression to use for filtering (``str``) - the action to take when the header matches the pattern. This can be ``'accept'``, ``'discard'``, ``'reject'``, or ``'hold'``. >>> print(header_matches.add('Subject', '^test: ', 'discard')) Header match on "subject" >>> print(header_matches) Header matches for "test-1.example.com" >>> len(header_matches) 1 >>> for hm in list(header_matches): ... print(hm) Header match on "subject" You can delete a header match by deleting it from the ``header_matches`` collection. >>> del header_matches[0] >>> len(header_matches) 0 You can also delete a header match using its ``delete()`` method, but be aware that the collection will not automatically be updated. Get a new collection from the list's ``header_matches`` attribute to see the change. >>> print(header_matches.add('Subject', '^test: ', 'discard')) Header match on "subject" >>> header_matches[0].delete() >>> len(header_matches) # not automatically updated 1 >>> len(test_one.header_matches) 0 Configuration ============= Mailman Core exposes all its configuration through REST API. All these configuration options are read-only. >>> cfg = client.configuration >>> for key in sorted(cfg): ... print(cfg[key].name) antispam archiver.mail_archive archiver.master archiver.mhonarc archiver.prototype bounces database devmode digests dmarc language.ar language.ast language.ca language.cs language.da language.de language.el language.en language.es language.et language.eu language.fi language.fr language.gl language.he language.hr language.hu language.ia language.it language.ja language.ko language.lt language.nl language.no language.pl language.pt language.pt_BR language.ro language.ru language.sk language.sl language.sr language.sv language.tr language.uk language.vi language.zh_CN language.zh_TW logging.archiver logging.bounce logging.config logging.database logging.debug logging.error logging.fromusenet logging.http logging.locks logging.mischief logging.plugins logging.root logging.runner logging.smtp logging.subscribe logging.vette mailman mta nntp passwords paths.dev paths.fhs paths.here paths.local plugin.master runner.archive runner.bad runner.bounces runner.command runner.digest runner.in runner.lmtp runner.nntp runner.out runner.pipeline runner.rest runner.retry runner.shunt runner.virgin shell styles webservice Each configuration object is a dictionary and you can iterate over them: >>> for key in sorted(cfg['mailman']): ... print('{} : {}'.format(key, cfg['mailman'][key])) cache_life : 7d default_language : en email_commands_max_lines : 10 filtered_messages_are_preservable : no html_to_plain_text_command : /usr/bin/lynx -dump $filename layout : here listname_chars : [-_.0-9a-z] noreply_address : noreply pending_request_life : 3d post_hook : pre_hook : self_link : http://localhost:9001/3.1/system/configuration/mailman sender_headers : from from_ reply-to sender site_owner : changeme@example.com .. >>> for domain in client.domains: ... domain.delete() >>> for user in client.users: ... user.delete() mailmanclient-3.2.2/src/mailmanclient/restbase/0000775000175000017500000000000013427703737022704 5ustar maxkingmaxking00000000000000mailmanclient-3.2.2/src/mailmanclient/restbase/__init__.py0000664000175000017500000000000013413656045024775 0ustar maxkingmaxking00000000000000mailmanclient-3.2.2/src/mailmanclient/restbase/base.py0000664000175000017500000001620213413656572024170 0ustar maxkingmaxking00000000000000# Copyright (C) 2010-2019 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . import six from collections import MutableMapping, Sequence __metaclass__ = type __all__ = [ 'RESTBase', 'RESTDict', 'RESTList', 'RESTObject' ] class RESTBase: """ Base class for data coming from the REST API. Subclasses can (and sometimes must) define some attributes to handle a particular entity. :cvar _properties: the list of expected entity properties. This is required for API elements that behave like an object, with REST data accessed through attributes. If this value is None, the REST data is used to list available properties. :cvar _writable_properties: list of properties that can be written to using a `PATCH` request. If this value is `None`, all properties are writable. :cvar _read_only_properties: list of properties that cannot be written to (defaults to `self_link` only). :cvar _autosave: automatically send a `PATCH` request to the API when a value is changed. Otherwise, the `save()` method must be called. """ _properties = None _writable_properties = None _read_only_properties = ['self_link'] _autosave = False def __init__(self, connection, url, data=None): """ :param connection: An API connection object. :type connection: Connection. :param url: The url of the API endpoint. :type url: str. :param data: The initial data to use. :type data: dict. """ self._connection = connection self._url = url self._rest_data = data self._changed_rest_data = {} def __repr__(self): return '<{0} at {1}>'.format(self.__class__.__name__, self._url) @property def rest_data(self): """Get data from API and cache it (only once per instance).""" if self._rest_data is None: response, content = self._connection.call(self._url) if isinstance(content, dict) and 'http_etag' in content: del content['http_etag'] # We don't care about etags. self._rest_data = content return self._rest_data def _get(self, key): if self._properties is not None: # Some REST key/values may not be returned by Mailman if the value # is None. if key in self._properties: return self.rest_data.get(key) raise KeyError(key) else: return self.rest_data[key] def _set(self, key, value): if (key in self._read_only_properties or ( self._writable_properties is not None and key not in self._writable_properties)): raise ValueError('value is read-only') # Don't check that the key is in _properties, the accepted values for # write may be different from the returned values (eg: User.password # and User.cleartext_password). if key in self.rest_data and self.rest_data[key] == value: return # Nothing to do self._changed_rest_data[key] = value if self._autosave: self.save() def _reset_cache(self): self._changed_rest_data = {} self._rest_data = None def save(self): response, content = self._connection.call( self._url, self._changed_rest_data, method='PATCH') self._reset_cache() class RESTObject(RESTBase): """Base class for REST data that behaves like an object with attributes.""" def __getattr__(self, name): try: return self._get(name) except KeyError: # Transform the KeyError into the more appropriate AttributeError raise AttributeError( "'{0}' object has no attribute '{1}'".format( self.__class__.__name__, name)) def __setattr__(self, name, value): # RESTObject must list REST-specific properties or we won't be able to # store the _connection, _url, etc. assert self._properties is not None if name not in self._properties: return super(RESTObject, self).__setattr__(name, value) return self._set(name, value) def delete(self): self._connection.call(self._url, method='DELETE') self._reset_cache() class RESTDict(RESTBase, MutableMapping): """Base class for REST data that behaves like a dictionary.""" def __repr__(self): return repr(self.rest_data) def __unicode__(self): return six.text_type(self.rest_data) def __getitem__(self, key): return self._get(key) def __setitem__(self, key, value): self._set(key, value) def __delitem__(self, key): raise NotImplementedError("REST dictionnary keys can't be deleted.") def __iter__(self): for key in self.rest_data: if self._properties is not None and key not in self._properties: continue yield key def __len__(self): return len(self.rest_data) def get(self, key, default=None): return self.rest_data.get(key, default) def keys(self): return list(self) def update(self, other): # Optimize the update to call save() only once _old_autosave = self._autosave self._autosave = False super(RESTDict, self).update(other) self._autosave = _old_autosave if self._autosave: self.save() class RESTList(RESTBase, Sequence): """ Base class for REST data that behaves like a list. The `_factory` attribute is a callable that will be applied on each returned member of the list. """ _factory = lambda x: x # noqa: E731 @property def rest_data(self): if self._rest_data is None: response, content = self._connection.call(self._url) if 'entries' not in content: self._rest_data = [] else: self._rest_data = content['entries'] return self._rest_data def __repr__(self): return repr(self.rest_data) def __unicode__(self): return six.text_type(self.rest_data) def __getitem__(self, key): return self._factory(self.rest_data[key]) def __delitem__(self, key): self[key].delete() self._reset_cache() def __len__(self): return len(self.rest_data) def __iter__(self): for entry in self.rest_data: yield self._factory(entry) def clear(self): self._connection.call(self._url, method='DELETE') self._reset_cache() mailmanclient-3.2.2/src/mailmanclient/restbase/connection.py0000664000175000017500000001017613413656572025421 0ustar maxkingmaxking00000000000000# Copyright (C) 2010-2019 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . import json from base64 import b64encode from urllib.error import HTTPError from urllib.parse import urljoin, urlencode from httplib2 import Http from mailmanclient.constants import __version__ __metaclass__ = type __all__ = [ 'MailmanConnectionError', 'Connection' ] class MailmanConnectionError(Exception): """Custom Exception to catch connection errors.""" class Connection: """A connection to the REST client.""" def __init__(self, baseurl, name=None, password=None): """Initialize a connection to the REST API. :param baseurl: The base url to access the Mailman 3 REST API. :param name: The Basic Auth user name. If given, the `password` must also be given. :param password: The Basic Auth password. If given the `name` must also be given. """ if baseurl[-1] != '/': baseurl += '/' self.baseurl = baseurl self.name = name self.password = password if name is not None and password is None: raise TypeError('`password` is required when `name` is given') if name is None and password is not None: raise TypeError('`name` is required when `password` is given') if name is None: self.basic_auth = None else: auth = '{0}:{1}'.format(name, password) self.basic_auth = b64encode(auth.encode('utf-8')).decode('utf-8') def call(self, path, data=None, method=None): """Make a call to the Mailman REST API. :param path: The url path to the resource. :type path: str :param data: Data to send, implies POST (default) or PUT. :type data: dict :param method: The HTTP method to call. Defaults to GET when `data` is None or POST if `data` is given. :type method: str :return: The response content, which will be None, a dictionary, or a list depending on the actual JSON type returned. :rtype: None, list, dict :raises HTTPError: when a non-2xx status code is returned. """ headers = { 'User-Agent': 'GNU Mailman REST client v{0}'.format(__version__), } data_str = None if data is not None: data_str = urlencode(data, doseq=True, encoding='utf-8') headers['Content-Type'] = 'application/x-www-form-urlencoded' if method is None: if data_str is None: method = 'GET' else: method = 'POST' method = method.upper() if self.basic_auth: headers['Authorization'] = 'Basic ' + self.basic_auth url = urljoin(self.baseurl, path) try: response, content = Http().request(url, method, data_str, headers) # If we did not get a 2xx status code, make this look like a # urllib2 exception, for backward compatibility. if response.status // 100 != 2: raise HTTPError(url, response.status, content, response, None) if len(content) == 0: return response, None # XXX Work around for http://bugs.python.org/issue10038 if isinstance(content, bytes): content = content.decode('utf-8') return response, json.loads(content) except HTTPError: raise except IOError: raise MailmanConnectionError('Could not connect to Mailman API') mailmanclient-3.2.2/src/mailmanclient/restbase/page.py0000664000175000017500000000527513413656572024202 0ustar maxkingmaxking00000000000000# Copyright (C) 2010-2019 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from six.moves.urllib_parse import urlencode, urlsplit, parse_qs, urlunsplit from mailmanclient.constants import DEFAULT_PAGE_ITEM_COUNT __metaclass__ = type __all__ = [ 'Page' ] class Page: def __init__(self, connection, path, model, count=DEFAULT_PAGE_ITEM_COUNT, page=1): self._connection = connection self._path = path self._count = count self._page = page self._model = model self._entries = [] self.total_size = 0 self._create_page() def __getitem__(self, key): return self._entries[key] def __iter__(self): for entry in self._entries: yield entry def __repr__(self): return ' 1 @property def has_next(self): return self._count * self._page < self.total_size mailmanclient-3.2.2/src/mailmanclient/restobjects/0000775000175000017500000000000013427703737023423 5ustar maxkingmaxking00000000000000mailmanclient-3.2.2/src/mailmanclient/restobjects/__init__.py0000664000175000017500000000000013413656045025514 0ustar maxkingmaxking00000000000000mailmanclient-3.2.2/src/mailmanclient/restobjects/address.py0000664000175000017500000000467513413656572025435 0ustar maxkingmaxking00000000000000# Copyright (C) 2010-2019 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from mailmanclient.restobjects.preferences import PreferencesMixin from mailmanclient.restbase.base import RESTList, RESTObject __metaclass__ = type __all__ = [ 'Address', 'Addresses' ] class Addresses(RESTList): def __init__(self, connection, url, data=None): super(Addresses, self).__init__(connection, url, data) self._factory = lambda data: Address( self._connection, data['self_link'], data) def find_by_email(self, email): for address in self: if address.email == email: return address return None def remove(self, email): address = self.find_by_email(email) if address is not None: address.delete() self._reset_cache() else: raise ValueError('The address {} does not exist'.format(email)) class Address(RESTObject, PreferencesMixin): _properties = ('display_name', 'email', 'original_email', 'registered_on', 'self_link', 'verified_on') def __repr__(self): return '
'.format(self.email) def __str__(self): return self.email @property def user(self): from mailmanclient.restobjects.user import User if 'user' in self.rest_data: return User(self._connection, self.rest_data['user']) else: return None @property def verified(self): return self.verified_on is not None def verify(self): self._connection.call( 'addresses/{0}/verify'.format(self.email), method='POST') self._reset_cache() def unverify(self): self._connection.call( 'addresses/{0}/unverify'.format(self.email), method='POST') self._reset_cache() mailmanclient-3.2.2/src/mailmanclient/restobjects/archivers.py0000664000175000017500000000307213413656572025764 0ustar maxkingmaxking00000000000000# Copyright (C) 2010-2019 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from mailmanclient.restbase.base import RESTDict __metaclass__ = type __all__ = [ 'ListArchivers' ] class ListArchivers(RESTDict): """ Represents the activation status for each site-wide available archiver for a given list. """ _autosave = True def __init__(self, connection, url, mlist): """ :param connection: An API connection object. :type connection: Connection. :param url: The API url of the list's archiver endpoint. :type url: str. :param mlist: The corresponding list object. :type mlist: MailingList. """ super(ListArchivers, self).__init__(connection, url) self._mlist = mlist def __repr__(self): return ''.format(self._mlist.list_id) def __str__(self): return 'Archivers on {}'.format(self._mlist.list_id) mailmanclient-3.2.2/src/mailmanclient/restobjects/ban.py0000664000175000017500000000640413413656572024540 0ustar maxkingmaxking00000000000000# Copyright (C) 2010-2019 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from six.moves.urllib_error import HTTPError from mailmanclient.restobjects.mailinglist import MailingList from mailmanclient.restbase.base import RESTList, RESTObject __metaclass__ = type __all__ = [ 'Bans', 'BannedAddress' ] class Bans(RESTList): """ The list of banned addresses from a mailing-list or from the whole site. """ def __init__(self, connection, url, data=None, mlist=None): """ :param mlist: The corresponding list object, or None if it is a global ban list. :type mlist: MailingList or None. """ super(Bans, self).__init__(connection, url, data) self._mlist = mlist self._factory = lambda data: BannedAddress( self._connection, data['self_link'], data) def __repr__(self): if self._mlist is None: return '' else: return ''.format(self._mlist.list_id) def __contains__(self, item): # Accept email addresses and BannedAddress restobjects if isinstance(item, BannedAddress): item = item.email if self._rest_data is not None: return item in [data['email'] for data in self._rest_data] else: # Avoid getting the whole list just to check membership try: response, content = self._connection.call( '{}/{}'.format(self._url, item)) except HTTPError as e: if e.code == 404: return False else: raise else: return True def add(self, email): response, content = self._connection.call(self._url, dict(email=email)) self._reset_cache() return BannedAddress(self._connection, response['location']) def find_by_email(self, email): for ban in self: if ban.email == email: return ban return None def remove(self, email): ban = self.find_by_email(email) if ban is not None: ban.delete() self._reset_cache() else: raise ValueError('The address {} is not banned'.format(email)) class BannedAddress(RESTObject): _properties = ('email', 'list_id', 'self_link') _writable_properties = [] def __repr__(self): return ''.format(self.email) def __str__(self): return self.email @property def mailinglist(self): return MailingList( self._connection, 'lists/{0}'.format(self.list_id)) mailmanclient-3.2.2/src/mailmanclient/restobjects/configuration.py0000664000175000017500000000217013413656572026643 0ustar maxkingmaxking00000000000000# Copyright (C) 2010-2019 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from mailmanclient.restbase.base import RESTDict __metaclass__ = type __all__ = [ 'Configuration' ] class Configuration(RESTDict): _writable_properties = () def __init__(self, connection, name): super(Configuration, self).__init__( connection, 'system/configuration/{}'.format(name)) self.name = name def __repr__(self): return ''.format(self.name) mailmanclient-3.2.2/src/mailmanclient/restobjects/domain.py0000664000175000017500000001043713413656572025250 0ustar maxkingmaxking00000000000000# Copyright (C) 2010-2019 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . import warnings from mailmanclient.restobjects.mailinglist import MailingList from mailmanclient.restobjects.templates import TemplateList from mailmanclient.restobjects.user import User from mailmanclient.restbase.base import RESTObject from mailmanclient.restbase.page import Page __metaclass__ = type __all__ = [ 'Domain' ] class Domain(RESTObject): _properties = ('alias_domain', 'description', 'mail_host', 'self_link') def __repr__(self): return ''.format(self.mail_host) def __str__(self): return self.mail_host @property def web_host(self): warnings.warn( 'The `Domain.web_host` attribute is deprecated. It is not used ' 'any more and will be removed in the future.', DeprecationWarning, stacklevel=2) return 'http://{}'.format(self.mail_host) @property def base_url(self): warnings.warn( 'The `Domain.base_url` attribute is deprecated. It is not used ' 'any more and will be removed in the future.', DeprecationWarning, stacklevel=2) return 'http://{}'.format(self.mail_host) @property def owners(self): url = self._url + '/owners' response, content = self._connection.call(url) if 'entries' not in content: return [] else: return [User(self._connection, entry['self_link'], entry) for entry in content['entries']] @property def lists(self): return self.get_lists() def get_lists(self, advertised=None): url = 'domains/{0}/lists'.format(self.mail_host) if advertised: url += '?advertised=true' response, content = self._connection.call(url) if 'entries' not in content: return [] return [MailingList(self._connection, entry['self_link'], entry) for entry in content['entries']] def get_list_page(self, count=50, page=1, advertised=None): url = 'domains/{0}/lists'.format(self.mail_host) if advertised: url += '?advertised=true' return Page(self._connection, url, MailingList, count, page) def create_list(self, list_name, style_name=None): fqdn_listname = '{0}@{1}'.format(list_name, self.mail_host) data = dict(fqdn_listname=fqdn_listname) if style_name is not None: data['style_name'] = style_name response, content = self._connection.call('lists', data) return MailingList(self._connection, response['location']) # TODO: Add this when the API supports removing a single owner. # def remove_owner(self, owner): # url = self._url + '/owners/{}'.format(owner) # response, content = self._connection.call( # url, method='DELETE') # return response def remove_all_owners(self): url = self._url + '/owners' response, content = self._connection.call( url, method='DELETE') return response def add_owner(self, owner): url = self._url + '/owners' response, content = self._connection.call( url, {'owner': owner}) @property def templates(self): url = self._url + '/uris' return TemplateList(self._connection, url) def set_template(self, template_name, uri, username=None, password=None): url = self._url + '/uris' data = {template_name: uri} if username is not None and password is not None: data['username'] = username data['password'] = password return self._connection.call(url, data, 'PATCH')[1] mailmanclient-3.2.2/src/mailmanclient/restobjects/header_match.py0000664000175000017500000000475413413656572026412 0ustar maxkingmaxking00000000000000# Copyright (C) 2010-2019 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from mailmanclient.restbase.base import RESTList, RESTObject __metaclass__ = type __all__ = [ 'HeaderMatch', 'HeaderMatches' ] class HeaderMatches(RESTList): """ The list of header matches for a mailing-list. """ def __init__(self, connection, url, mlist): """ :param mlist: The corresponding list object. :type mlist: MailingList. """ super(HeaderMatches, self).__init__(connection, url) self._mlist = mlist self._factory = lambda data: HeaderMatch( self._connection, data['self_link'], data) def __repr__(self): return ''.format(self._mlist.list_id) def __str__(self): return 'Header matches for "{}"'.format(self._mlist.list_id) def add(self, header, pattern, action=None): """ :param header: The header to consider. :type header: str :param pattern: The regular expression to use for filtering. :type pattern: str :param action: The action to take when the header matches the pattern. This can be 'accept', 'discard', 'reject', or 'hold'. :type action: str """ data = dict(header=header, pattern=pattern) if action is not None: data['action'] = action response, content = self._connection.call(self._url, data) self._reset_cache() return HeaderMatch(self._connection, response['location']) class HeaderMatch(RESTObject): _properties = ('header', 'pattern', 'position', 'action', 'self_link') _writable_properties = ('header', 'pattern', 'position', 'action') def __repr__(self): return ''.format(self.header) def __str__(self): return 'Header match on "{}"'.format(self.header) mailmanclient-3.2.2/src/mailmanclient/restobjects/held_message.py0000664000175000017500000000354213413656572026420 0ustar maxkingmaxking00000000000000# Copyright (C) 2010-2019 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . import six from mailmanclient.restbase.base import RESTObject __metaclass__ = type __all__ = [ 'HeldMessage' ] class HeldMessage(RESTObject): _properties = ('hold_date', 'message_id', 'msg', 'reason', 'request_id', 'self_link', 'sender', 'subject', 'type') def __repr__(self): return ''.format( self.request_id, self.sender) def __unicode__(self): return six.text_type(self.rest_data) def moderate(self, action): """Moderate a held message. :param action: Action to perform on held message. :type action: String. """ response, content = self._connection.call( self._url, dict(action=action), 'POST') return response def discard(self): """Shortcut for moderate.""" return self.moderate('discard') def reject(self): """Shortcut for moderate.""" return self.moderate('reject') def defer(self): """Shortcut for moderate.""" return self.moderate('defer') def accept(self): """Shortcut for moderate.""" return self.moderate('accept') mailmanclient-3.2.2/src/mailmanclient/restobjects/mailinglist.py0000664000175000017500000003627613413656572026326 0ustar maxkingmaxking00000000000000# Copyright (C) 2010-2019 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . import warnings from operator import itemgetter from six.moves.urllib_error import HTTPError from six.moves.urllib_parse import urlencode from mailmanclient.restobjects.header_match import HeaderMatches from mailmanclient.restobjects.archivers import ListArchivers from mailmanclient.restobjects.member import Member from mailmanclient.restobjects.settings import Settings from mailmanclient.restobjects.held_message import HeldMessage from mailmanclient.restobjects.templates import TemplateList from mailmanclient.restbase.base import RESTObject from mailmanclient.restbase.page import Page __metaclass__ = type __all__ = [ 'MailingList' ] class MailingList(RESTObject): _properties = ('display_name', 'fqdn_listname', 'list_id', 'list_name', 'mail_host', 'member_count', 'volume', 'self_link', 'description') def __init__(self, connection, url, data=None): super(MailingList, self).__init__(connection, url, data) self._settings = None def __repr__(self): return ''.format(self.fqdn_listname) @property def owners(self): url = self._url + '/roster/owner' response, content = self._connection.call(url) if 'entries' not in content: return [] else: return [Member(self._connection, entry['self_link'], entry) for entry in sorted(content['entries'], key=itemgetter('address'))] @property def moderators(self): url = self._url + '/roster/moderator' response, content = self._connection.call(url) if 'entries' not in content: return [] else: return [Member(self._connection, entry['self_link'], entry) for entry in sorted(content['entries'], key=itemgetter('address'))] @property def members(self): url = 'lists/{0}/roster/member'.format(self.fqdn_listname) response, content = self._connection.call(url) if 'entries' not in content: return [] return [Member(self._connection, entry['self_link'], entry) for entry in sorted(content['entries'], key=itemgetter('address'))] @property def nonmembers(self): url = 'members/find' data = {'role': 'nonmember', 'list_id': self.list_id} response, content = self._connection.call(url, data) if 'entries' not in content: return [] return [Member(self._connection, entry['self_link'], entry) for entry in sorted(content['entries'], key=itemgetter('address'))] def get_member_page(self, count=50, page=1): url = 'lists/{0}/roster/member'.format(self.fqdn_listname) return Page(self._connection, url, Member, count, page) def find_members(self, address=None, role=None, page=None, count=50): data = {'list_id': self.list_id} if address: data['subscriber'] = address if role: data['role'] = role url = 'members/find?{}'.format(urlencode(data, doseq=True)) if page is None: response, content = self._connection.call(url, data) if 'entries' not in content: return [] return [Member(self._connection, entry['self_link'], entry) for entry in content['entries']] else: return Page(self._connection, url, Member, count, page) @property def settings(self): if self._settings is None: self._settings = Settings( self._connection, 'lists/{0}/config'.format(self.fqdn_listname)) return self._settings @property def held(self): """Return a list of dicts with held message information.""" response, content = self._connection.call( 'lists/{0}/held'.format(self.fqdn_listname), None, 'GET') if 'entries' not in content: return [] return [HeldMessage(self._connection, entry['self_link'], entry) for entry in content['entries']] def get_held_page(self, count=50, page=1): url = 'lists/{0}/held'.format(self.fqdn_listname) return Page(self._connection, url, HeldMessage, count, page) def get_held_message(self, held_id): url = 'lists/{0}/held/{1}'.format(self.fqdn_listname, held_id) return HeldMessage(self._connection, url) @property def requests(self): """Return a list of dicts with subscription requests.""" response, content = self._connection.call( 'lists/{0}/requests'.format(self.fqdn_listname), None, 'GET') if 'entries' not in content: return [] else: entries = [] for entry in content['entries']: request = dict(email=entry['email'], token=entry['token'], token_owner=entry['token_owner'], list_id=entry['list_id'], request_date=entry['when']) entries.append(request) return entries @property def archivers(self): url = 'lists/{0}/archivers'.format(self.list_id) return ListArchivers(self._connection, url, self) @archivers.setter def archivers(self, new_value): url = 'lists/{0}/archivers'.format(self.list_id) archivers = ListArchivers(self._connection, url, self) archivers.update(new_value) archivers.save() def add_owner(self, address, display_name=None): self.add_role('owner', address, display_name) def add_moderator(self, address, display_name=None): self.add_role('moderator', address, display_name) def add_role(self, role, address, display_name=None): data = dict(list_id=self.list_id, subscriber=address, display_name=display_name, role=role) self._connection.call('members', data) def remove_owner(self, address): self.remove_role('owner', address) def remove_moderator(self, address): self.remove_role('moderator', address) def remove_role(self, role, address): url = 'lists/%s/%s/%s' % (self.fqdn_listname, role, address) self._connection.call(url, method='DELETE') def moderate_message(self, request_id, action): """Moderate a held message. :param request_id: Id of the held message. :type request_id: Int. :param action: Action to perform on held message. :type action: String. """ path = 'lists/{0}/held/{1}'.format( self.fqdn_listname, str(request_id)) response, content = self._connection.call( path, dict(action=action), 'POST') return response def discard_message(self, request_id): """Shortcut for moderate_message.""" return self.moderate_message(request_id, 'discard') def reject_message(self, request_id): """Shortcut for moderate_message.""" return self.moderate_message(request_id, 'reject') def defer_message(self, request_id): """Shortcut for moderate_message.""" return self.moderate_message(request_id, 'defer') def accept_message(self, request_id): """Shortcut for moderate_message.""" return self.moderate_message(request_id, 'accept') def moderate_request(self, request_id, action): """ Moderate a subscription request. :param action: accept|reject|discard|defer :type action: str. """ path = 'lists/{0}/requests/{1}'.format(self.list_id, request_id) response, content = self._connection.call(path, {'action': action}) return response def manage_request(self, token, action): """Alias for moderate_request, kept for compatibility""" warnings.warn( 'The `manage_request()` method has been replaced by ' '`moderate_request()` and will be removed in the future.', DeprecationWarning, stacklevel=2) return self.moderate_request(token, action) def accept_request(self, request_id): """Shortcut to accept a subscription request.""" return self.moderate_request(request_id, 'accept') def reject_request(self, request_id): """Shortcut to reject a subscription request.""" return self.moderate_request(request_id, 'reject') def discard_request(self, request_id): """Shortcut to discard a subscription request.""" return self.moderate_request(request_id, 'discard') def defer_request(self, request_id): """Shortcut to defer a subscription request.""" return self.moderate_request(request_id, 'defer') def get_member(self, email): """Get a membership. :param address: The email address of the member for this list. :return: A member proxy object. """ # In order to get the member object we query the REST API for # the member. Incase there is no matching subscription, an # HTTPError is returned instead. try: path = 'lists/{0}/member/{1}'.format(self.list_id, email) response, content = self._connection.call(path) return Member(self._connection, content['self_link'], content) except HTTPError: raise ValueError('%s is not a member address of %s' % (email, self.fqdn_listname)) def subscribe(self, address, display_name=None, pre_verified=False, pre_confirmed=False, pre_approved=False): """Subscribe an email address to a mailing list. :param address: Email address to subscribe to the list. :type address: str :param display_name: The real name of the new member. :param pre_verified: True if the address has been verified. :type pre_verified: bool :param pre_confirmed: True if membership has been approved by the user. :type pre_confirmed: bool :param pre_approved: True if membership is moderator-approved. :type pre_approved: bool :type display_name: str :return: A member proxy object. """ data = dict( list_id=self.list_id, subscriber=address, display_name=display_name, ) if pre_verified: data['pre_verified'] = True if pre_confirmed: data['pre_confirmed'] = True if pre_approved: data['pre_approved'] = True response, content = self._connection.call('members', data) # If a member is not immediately subscribed (i.e. verificatoin, # confirmation or approval need), the response content is returned. if response.status == 202: return content # I the subscription is executed immediately, a member object # is returned. return Member(self._connection, response['location']) def unsubscribe(self, email): """Unsubscribe an email address from a mailing list. :param address: The address to unsubscribe. """ # In order to get the member object we need to # iterate over the existing member list try: path = 'lists/{0}/member/{1}'.format(self.list_id, email) self._connection.call(path, method='DELETE') except HTTPError: # The member link does not exist, i.e. he is not a member raise ValueError('%s is not a member address of %s' % (email, self.fqdn_listname)) @property def bans(self): from mailmanclient.restobjects.ban import Bans url = 'lists/{0}/bans'.format(self.list_id) return Bans(self._connection, url, mlist=self) def get_bans_page(self, count=50, page=1): from mailmanclient.restobjects.ban import BannedAddress url = 'lists/{0}/bans'.format(self.list_id) return Page(self._connection, url, BannedAddress, count, page) @property def header_matches(self): url = 'lists/{0}/header-matches'.format(self.list_id) return HeaderMatches(self._connection, url, self) @property def templates(self): url = self._url + '/uris' return TemplateList(self._connection, url) def set_template(self, template_name, uri, username=None, password=None): url = self._url + '/uris' data = {template_name: uri} if username is not None and password is not None: data['username'] = username data['password'] = password return self._connection.call(url, data, 'PATCH')[1] def _check_membership(self, address, allowed_roles): """ Given an address and role, check if there is a membership record that matches the given address with a given role for this Mailing List. """ url = 'members/find' data = {'subscriber': address, 'list_id': self.list_id} response, content = self._connection.call(url, data=data) if 'entries' not in content: return False for membership in content['entries']: # We check for all the returned roles for this User and MailingList if membership['role'] in allowed_roles: return True return False def is_owner(self, address): """ Given an address, checks if the given address is an owner of this mailing list. """ return self._check_membership(address=address, allowed_roles=('owner',)) def is_moderator(self, address): """ Given an address, checks if the given address is a moderator of this mailing list. """ return self._check_membership(address=address, allowed_roles=('moderator',)) def is_member(self, address): """ Given an address, checks if the given address is subscribed to this mailing list. """ return self._check_membership(address=address, allowed_roles=('member',)) def is_owner_or_mod(self, address): """ Given an address, checks if the given address is either a owner or a moderator of this list. It is possible for them to be both owner and moderator. """ return self._check_membership(address=address, allowed_roles=('owner', 'moderator')) mailmanclient-3.2.2/src/mailmanclient/restobjects/member.py0000664000175000017500000000364313413656572025251 0ustar maxkingmaxking00000000000000# Copyright (C) 2010-2019 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from mailmanclient.restobjects.preferences import PreferencesMixin from mailmanclient.restbase.base import RESTObject __metaclass__ = type __all__ = [ 'Member' ] class Member(RESTObject, PreferencesMixin): _properties = ('delivery_mode', 'email', 'list_id', 'moderation_action', 'display_name', 'role', 'self_link') _writable_properties = ('address', 'delivery_mode', 'moderation_action') def __repr__(self): return ''.format(self.email, self.list_id) def __str__(self): return 'Member "{0}" on "{1}"'.format(self.email, self.list_id) def __unicode__(self): return u'Member "{0}" on "{1}"'.format(self.email, self.list_id) @property def address(self): from mailmanclient.restobjects.address import Address return Address(self._connection, self.rest_data['address']) @property def user(self): from mailmanclient.restobjects.user import User return User(self._connection, self.rest_data['user']) def unsubscribe(self): """Unsubscribe the member from a mailing list. """ # TODO: call .delete() instead? self._connection.call(self.self_link, method='DELETE') mailmanclient-3.2.2/src/mailmanclient/restobjects/preferences.py0000664000175000017500000000274613413656572026306 0ustar maxkingmaxking00000000000000# Copyright (C) 2010-2019 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from mailmanclient.restbase.base import RESTDict __metaclass__ = type __all__ = [ 'Preferences', 'PreferencesMixin' ] class Preferences(RESTDict): _properties = ( 'acknowledge_posts', 'delivery_mode', 'delivery_status', 'hide_address', 'preferred_language', 'receive_list_copy', 'receive_own_postings', ) def delete(self): response, content = self._connection.call(self._url, method='DELETE') class PreferencesMixin: """Mixin for restobjects that have preferences.""" @property def preferences(self): if getattr(self, '_preferences', None) is None: path = '{0}/preferences'.format(self.self_link) self._preferences = Preferences(self._connection, path) return self._preferences mailmanclient-3.2.2/src/mailmanclient/restobjects/queue.py0000664000175000017500000000231613413656572025122 0ustar maxkingmaxking00000000000000# Copyright (C) 2010-2019 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from mailmanclient.restbase.base import RESTObject __metaclass__ = type __all__ = [ 'Queue' ] class Queue(RESTObject): _properties = ('name', 'directory', 'files') def __repr__(self): return ''.format(self.name) def inject(self, list_id, text): self._connection.call(self._url, dict(list_id=list_id, text=text)) @property def files(self): # No caching. response, content = self._connection.call(self._url) return content['files'] mailmanclient-3.2.2/src/mailmanclient/restobjects/settings.py0000664000175000017500000000251513413656572025637 0ustar maxkingmaxking00000000000000# Copyright (C) 2010-2019 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from mailmanclient.restbase.base import RESTDict __metaclass__ = type __all__ = [ 'Settings' ] class Settings(RESTDict): _read_only_properties = ( 'bounces_address', 'created_at', 'digest_last_sent_at', 'fqdn_listname', 'join_address', 'last_post_at', 'leave_address', 'list_id', 'list_name', 'mail_host', 'next_digest_number', 'no_reply_address', 'owner_address', 'post_id', 'posting_address', 'request_address', 'scheme', 'self_link', 'volume', 'web_host', ) mailmanclient-3.2.2/src/mailmanclient/restobjects/styles.py0000664000175000017500000000156613413656572025327 0ustar maxkingmaxking00000000000000# Copyright (C) 2018-2019 by the Free Software Foundation, Inc. # # This file is part of mailmanclient. # # mailmanclient is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailmanclient is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailmanclient. If not, see . from mailmanclient.restbase.base import RESTDict class Styles(RESTDict): _read_only_properties = ( 'style_names', 'styles', 'default' ) mailmanclient-3.2.2/src/mailmanclient/restobjects/templates.py0000664000175000017500000000262613413656572026000 0ustar maxkingmaxking00000000000000# Copyright (C) 2017-2019 by the Free Software Foundation, Inc. # # This file is part of mailman.client. # # mailman.client is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by the # Free Software Foundation, version 3 of the License. # # mailman.client is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY # or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public # License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with mailman.client. If not, see . """Template objects.""" from __future__ import absolute_import, print_function, unicode_literals from mailmanclient.restbase.base import RESTList, RESTObject __all__ = [ 'Template', 'TemplateList' ] class TemplateList(RESTList): def __init__(self, connection, url, data=None, context=None): super(RESTList, self).__init__(connection, url, data) self._factory = lambda data: Template( self._connection, data['self_link'], data) class Template(RESTObject): _properties = ('self_link', 'name', 'uri', 'username', 'password') _writable_properties = ['uri', 'username', 'password'] def __repr__(self): return '